Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Model Expires Laravel Package

maize-tech/laravel-model-expires

A Laravel package that gives Eloquent models expiration dates. Add the trait, set an expiry, and use scopes to query active or expired records. Includes commands to prune expired models automatically, keeping your database clean and your data fresh.

View on GitHub
Deep Wiki
Context7

Getting Started

maize-tech/laravel-model-expires gives an Eloquent model a first-class notion of "expiry" via a single timestamp column, plus scopes and helpers so you don't hand-roll where('expires_at', '<=', now()) in every query.

Minimal setup:

composer require maize-tech/laravel-model-expires
// migration
Schema::table('promotions', function (Blueprint $table) {
    $table->timestamp('expires_at')->nullable()->index();
});
use Maize\ModelExpires\Traits\Expirable;

class Promotion extends Model
{
    use Expirable;
}

Look first at the package's config file (php artisan vendor:publish --tag=model-expires-config) and at the Expirable trait itself — it's small, and reading it is faster than reading the README. Confirm the exact column name and scope names in your installed version before writing queries against them.

First real use case: time-limited content — promo banners, job listings, invite codes, trial passes. You add the column, add the trait, and then every read path can be made time-aware with one scope call instead of a scattered where.

Version note (1.2.0): this release is maintenance-only — dependabot bumps to the GitHub Actions, plus Laravel 12 and 13 support. There are no API changes, deprecations, or migration work. If your app is on Laravel 12/13, this is the minimum version you need; if you're on 9–11, upgrading is a no-op for your code.

Implementation Patterns

Two ways to read time-aware data — pick one per surface.

Promotion::notExpired()->get();          // public API / listings
Promotion::expired()->get();             // admin "archive" screens, cleanup jobs

Compose these with your normal constraints rather than replacing them:

Promotion::notExpired()
    ->where('locale', $locale)
    ->orderByDesc('created_at')
    ->paginate();

Setting expiry at write time. Set expires_at explicitly from a Carbon instance in your form request / action class. Keep the decision in one place (a PromotionData DTO or an expireAt() mutator) so tests and seeders produce consistent values.

Factories with states pay off immediately:

public function expired(): static
{
    return $this->state(fn () => ['expires_at' => now()->subDay()]);
}

public function expiringSoon(): static
{
    return $this->state(fn () => ['expires_at' => now()->addHour()]);
}

Then Promotion::factory()->expired()->create() makes scope tests trivial with travelTo().

Scheduled sweeps. Expiry is a passive attribute — nothing "happens" when the clock crosses expires_at. If you need an action (notify the owner, soft-delete, purge a cache entry, send a "your listing ended" mail), you own the scheduling:

// app/Console/Commands/ExpirePromotions.php
Promotion::expired()
    ->whereNull('archived_at')
    ->chunkById(500, fn ($rows) => $rows->each->archive());

Register it in routes/console.php / the scheduler and make it idempotent — the job will re-see the same rows on the next run unless your handler marks them.

Combine with SoftDeletes deliberately. "Expired" and "deleted" are different states and users usually expect expired content to still be recoverable or visible to its owner. A common split: notExpired() for public reads, expired() for the owner's dashboard, and a separate pruning job weeks later that soft-deletes.

Test the boundary, not just the scope. Freeze time and assert the flip at the exact second — travelTo($expiresAt->subSecond()) vs travelTo($expiresAt) — because off-by-a-second comparisons against nullable timestamps are where this bites.

Gotchas and Tips

No implicit global scope (verify on your version). The trait gives you scopes and helpers; it does not typically hide expired rows from Promotion::all() or $promotion->children. Every query surface must opt in. Audit your read paths — an unfiltered relation ($user->promotions) will happily return expired records. If you want default hiding, add your own global scope in a base model and use withoutGlobalScope() in admin code, rather than relying on the package.

Timezone mismatch is the classic bug. expires_at written by MySQL's CURRENT_TIMESTAMP (server TZ) compared against now() (app TZ) silently shifts expiry by hours. Pick one source of truth — write from PHP with Carbon, or set DB timezone in config/database.php — and never mix.

Index the column. Every listing query now filters on expires_at alongside locale/status. A composite index on (expires_at, status) or similar is usually what you want; a bare nullable timestamp on a large table gives you a full scan on the notExpired() path.

Nullable means "never expires". Decide explicitly whether null is allowed. It's convenient, but it means where('expires_at', '<=', now()) alone never matches non-expiring rows — which is correct — while a naive whereNotNull in a report will quietly drop half your data. Document the convention.

Cache and index invalidation. Anything that caches listing results (response cache, Redis list, search index) will keep serving expired content past the timestamp. Either keep TTLs shorter than your shortest expiry window or have the sweep job bust the relevant keys.

Casts matter. Ensure expires_at is cast to datetime (Laravel does this for *_at by convention, but check). Comparing a raw string attribute with now() gives false results depending on driver.

Config caching. If you publish and edit the config, remember php artisan config:cache in deploy — a stale cached config is the usual reason a renamed column "doesn't work" locally but does in CI, or vice versa.

Extension points. The trait is thin by design: override the column name or add isExpired()-style helpers in your own concern if you need per-model variation, and hang observers/events off your own sweep job rather than trying to make the package fire something at expiry time. For anything more elaborate (expiry with grace periods, per-tenant windows), wrap it in a domain method on the model instead of spreading Carbon math through controllers.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core