alajusticia/laravel-expirable
Laravel Expirable lets you make any Eloquent model expirable with a simple trait. Add expiration timestamps, query scopes for active or expired records, and helper methods to expire or restore entries, making time-limited data like trials, posts, or tokens easy to manage.
Install the package and publish its migration/config in the usual Laravel way:
composer require michaelnabil230/laravel-expirable
php artisan vendor:publish --tag="expirable-migrations"
php artisan migrate
Add the Expirable trait to any Eloquent model that should be able to expire, and add the expired_at column through the package's schema macro in that model's migration:
use MichaelNabil230\Expirable\Traits\Expirable;
class Coupon extends Model
{
use Expirable;
}
Schema::create('coupons', function (Blueprint $table) {
$table->id();
$table->expirable(); // adds the nullable expired_at timestamp
$table->timestamps();
});
The first day-to-day use case is the obvious one: a Coupon, Post, Subscription, or Invitation gets a nullable datetime, null means "eternal" (never expires), and a value means "expired at that moment". From there:
$coupon->expire(); // set expired_at = now()
$coupon->expireInDays(7); // schedule expiry
Coupon::expired()->get(); // already expired
Coupon::notExpired()->get(); // live records only
Look first at the trait itself and the published migration stub — the exact method names and column name are the contract you'll build the rest of your domain queries on.
Query scoping is the core workflow. The package does not force a global scope on you, so "active" records remain queryable unless you ask otherwise. Be explicit at the boundary of your app:
// Default listing: hide expired rows
$coupons = Coupon::query()->notExpired()->latest()->paginate();
// Admin/history view: include everything
$all = Coupon::withExpired()->get();
// Expired-only report
$stale = Coupon::onlyExpired()->get();
// Eternal records (no expiry at all)
$permanent = Coupon::onlyEternal()->get();
// Everything that lapsed since a given point in time
$recentlyLapsed = Coupon::expiredSince(now()->subWeek())->get();
expiredSince() is the one you'll reach for in reporting jobs and dashboards ("what expired this week?"), and it now takes properly typed date parameters — pass a Carbon instance or a date string, not a raw integer.
Scheduling and bulk operations. expireByKey() is the efficient path when you're sweeping a large table from a queued job rather than loading models:
class ExpireCoupons implements ShouldQueue
{
public function handle(): void
{
Coupon::query()
->notExpired()
->where('expires_at', '<=', now())
->pluck('id')
->chunk(500)
->each(fn ($ids) => Coupon::expireByKey($ids));
}
}
Prefer this over ->each->expire() when the row count is large; you skip hydration and per-model events you probably don't need.
Custom builders and relationships. Because the scopes live on the trait, they compose with relationships and custom query builders:
class User extends Model
{
public function coupons(): HasMany
{
return $this->hasMany(Coupon::class);
}
}
$user->coupons()->notExpired()->count();
$user->coupons()->expiredSince($since)->get();
If you have a custom builder (Coupon::query() returning your own CouponBuilder), the scopes return $this, so chaining keeps your builder type. This is exactly what v2.6.0's PHPStan work locks in — see Gotchas.
Schema macros in tests. expirable() and dropExpirable() are available in migrations and in test scaffolding, which is convenient for in-memory SQLite:
public function up(): void
{
Schema::table('users', fn (Blueprint $table) => $table->expirable());
}
public function down(): void
{
Schema::table('users', fn (Blueprint $table) => $table->dropExpirable());
}
Composing with your own domain logic. Wrap the package scopes in named scopes that encode your business rules, so notExpired() semantics stay in one place:
public function scopeActive(Builder $query): Builder
{
return $query->notExpired();
}
Then use active() throughout the app and let the package own the timestamp comparison.
null is not "expired". Eternal records are excluded from expired() and onlyExpired(), and included in notExpired(). If you write your own where('expired_at', '<', now()) you will silently drop eternal rows — use onlyEternal() when you specifically mean "no expiry", and rely on the package scopes for the common cases.
No global scope means forgotten filters. The single most common production bug with expirable packages is a listing endpoint that forgets notExpired(). Audit every controller/resource that returns these models, or add a repository method that always applies it. Don't blanket-apply a global scope unless you're prepared for admin panels to lose visibility of expired data.
Timezone and precision. The expired_at column is a timestamp; comparisons run against your app timezone (config('app.timezone')) via Carbon. Store UTC consistently, and remember that an expiry "at 00:00" means it is expired for the entire day if you intended a date-only semantic. Use endOfDay() when scheduling day-granularity expiries.
Cast it. Add expired_at to $casts as datetime (or immutable_datetime) so comparisons and serialization behave predictably. Without the cast you'll get strings in JSON responses and subtle comparison bugs in PHP.
Index the column. Every scope is a where on expired_at. Add an index in your migration ($table->expirable() plus $table->index('expired_at'), or a composite index with your tenant/status column) before tables grow.
PHPStan: install the extension installer. v2.6.0 ships a PHPStan extension registered automatically through extension.neon. That automatic registration only works when phpstan/extension-installer is present in composer.json and allowed in config.platform / extra.phpstan — verify it is, otherwise include vendor/.../extension.neon in your phpstan.neon includes manually. This release is specifically worth re-running PHPStan for: withExpired(), onlyExpired(), onlyEternal(), and expiredSince() now preserve model and custom builder types, and the previously loose types on date parameters, fluent $this returns, expireByKey() IDs, and the expirable() / dropExpirable() macros are fixed. If you had @phpstan-ignore or baseline entries suppressing those errors, drop them and re-baseline — leaving them in masks future regressions. Expect a few new errors where the stricter types reveal genuinely wrong argument types in your jobs and controllers; those are real bugs, not noise.
Upgrading to 2.6.0 is non-breaking at the runtime level — it's a static analysis release. The only behavioural change is in your CI: PHPStan level checks may now fail on code that previously passed. Run the analyser in a branch first.
Extension points. Customising the column name or the trait's behaviour is best done by extending the trait and overriding the relevant methods rather than editing vendor code; the scopes and schema macros are all trait/blueprint macros, so they are straightforward to wrap in your own base model and base migration helper.
Testing. Use Carbon::setTestNow() so expiry boundaries are deterministic, and assert against the scopes rather than raw columns — that way a future release changing comparison semantics (inclusive vs exclusive at the exact expiry instant) surfaces as a failing test rather than a silent behaviour change.
How can I help you explore Laravel packages today?