jeffersongoncalves/laravel-saved-filter
Laravel Saved Filter lets users save reusable filter combinations for Eloquent queries and listings, so complex search criteria can be restored later. It provides database-backed storage and a simple API for persisting and applying filters.
Install, publish, and migrate — including the new stub. The package still follows the standard Laravel layout: config and migrations are publishable by tag. As of 1.10.0 there are three stubs: saved_filters, the pagination/grouping columns, and the new nullable timezone column. Publish them all and migrate.
composer require <vendor>/saved-filter
php artisan vendor:publish --tag="saved-filter-config"
php artisan vendor:publish --tag="saved-filter-migrations"
php artisan migrate
If you already run this package, only the timezone stub is new — the ones you ran before are skipped by their guards.
Verify the schema before writing any code. saved_filters exists, the pagination/grouping columns exist, and the timezone column exists (Schema::hasColumn('saved_filters', 'timezone')). The package does not load migrations itself; a missed publish is a clean install with no tables and a silent "table not found" at runtime.
Look first at the published config, the SavedFilter model, and the schedule command. The config tells you how filters are stored, which user relation is used, and whether scoping is on. The model is your extension point for casts, relations, scopes. The schedule/report command is where timezone resolution and occurrence computing happen — read it before you assume anything about cron semantics.
First use case: put an existing listing filter on a schedule. Take the index page's saved filter (search + sort + toggles, per_page, group_by), attach a timezone, and let the user receive it daily at 08:00 their wall clock. The timezone column and describeSchedule() exist exactly for this. The DST fix means that promise now holds across clock changes.
Save the raw request state, resolve it on read. Store the filter payload as the array of validated query parameters, not a hand-built query. Reconstruction logic lives in one place (a query scope or an ApplyFilters action), so adding a UI filter needs no schema change.
$filter = SavedFilter::create([
'user_id' => $request->user()->id,
'name' => $request->validated('name'),
'filters' => $request->validated('filters'),
'per_page' => $request->integer('per_page', 25),
'group_by' => $request->validated('group_by'),
'timezone' => $request->validated('timezone'), // null = app timezone
]);
Treat the saved filter as a view-model, not a query builder. Resolve it into a DTO / array of conditions, then feed that into your existing index query. The same resolved object should drive the live index page and the scheduled send, otherwise the emailed report and the web page drift.
Scope by user and by resource. Always constrain by owner and by the resource (a resource or filterable_* column). A shared table keyed only on user_id will leak an orders filter onto an invoices page — and now also into an email.
SavedFilter::query()
->where('user_id', $request->user()->id)
->where('resource', 'orders')
->orderBy('name')
->get();
Set the timezone explicitly when the user cares; leave it null when they don't. null means "follow the app timezone", which is the right default for single-region apps — a future config('app.timezone') change then moves every report coherently. Only write an IANA name when the user picked one. Validate against timezone_identifiers_list() on write, and still rely on the fallback on read.
Render the schedule from describeSchedule(), never re-implement it. It names the zone only when it differs from the app's (“Sent every day at 08:00 (America/Sao_Paulo)”), which is precisely the signal a user needs to spot a wrong setting. Duplicate this formatting and it will drift from the command's actual behaviour.
Use the builder context, don't reconstruct it. Scheduled sends now always pass saved_filter_id, report_id, and tenant_id. Bind them into your Mailable/view so the email can link back to the live filtered view and offer a signed unsubscribe on report_id. Read them from the context rather than threading them through your own job payload.
Key the signed unsubscribe on report_id only. A saved_filter_id is not a delivery identity — the same filter can be scheduled multiple ways, and deleting a filter shouldn't silently invalidate an unsubscribe link already sitting in someone's inbox. Treat report_id as the stable handle and validate it against the authenticated/signed payload.
Never compute occurrences yourself. Don't add 24 hours, don't startOfDay()->addHours(8), don't cache a "next run" timestamp across a DST boundary. 1.10.0 computes occurrences from the date precisely because stepping a shifted time back produced doubles on the Lisbon 01:30 case. If you have custom scheduling code from before, delete it and call the package.
Cache-aware recall and caching of results. Key result caches on the saved filter's ID plus updated_at so editing invalidates cleanly. Do not include the next-run timestamp in that key unless you recompute it — a cached next-run goes stale on DST changeover days.
Keep the HasSavedFilters controller trait, and extend it for scheduling. One index/store/apply/destroy surface across every listing, plus schedule/unschedule that authorise on the same ownership check. One place to enforce policy, one place to validate timezone and group_by.
Test around the transitions, not just the happy path. Freeze time and travel across the last Sunday of March and October in a DST zone (Lisbon, São Paulo) and assert on send count and send time. The release exists because that case was wrong.
vendor:publish --tag="saved-filter-migrations" before migrate on upgrade. Without it you get a working install with a missing column and a QueryException the first time a report is scheduled. Put the publish step in your deploy script and your CI install step.timezone stub is nullable and guarded, so a 1.0/1.1/1.2 install upgrades in place. Existing rows get null, which means "app timezone" — i.e. current behaviour is preserved with no data migration. But note this also means every existing report silently follows a future config('app.timezone') change. If reports were promised in a specific zone, backfill explicitly.hasTable/hasColumn checks. Don't strip them when you edit a stub.vendor:publish won't clobber without --force. Re-running is a no-op for files you already published — which is exactly why you need to re-run it for new stubs, and exactly why --force is dangerous on stubs you've customised. Diff first.describeSchedule()) in the UI so the fallback is visible rather than silent.sent_at day-truncation, skip-if-already-sent guards keyed on hour) or you risk suppressing legitimate sends.saved_filter_id in the builder context can point at a deleted filter. Soft-delete or null-check it in the email render path — a stored id outlives the row. The link should degrade to a plain listing URL rather than 404 mid-campaign.group_by and per_page are UI state that outlives schema. Validate on read against an allow-list (in: list for groupable columns, permitted page sizes) and fall back to no grouping. Never interpolate them into groupBy() unchecked. A filter saved with group_by: 'status' throws months later when that column is renamed — and now throws inside a queued send.saved_filters exist, (2) do the pagination/grouping columns exist, (3) does the timezone column exist, (4) is the effective timezone what you expect (describeSchedule()), (5) is the config's user scoping pointing at the right guard/model. On a new environment (1)–(3) are the likely culprits; on a timezone complaint it's (4); on a missing email it's (5).hasMigrations() tells you where migration registration lives — hook there (conditional on config) if you genuinely need package-loaded migrations, e.g. multi-tenant databases migrated per tenant. Remember the timezone stub must be part of that story too.migrate:fresh remains the safe reset point. A fresh database is fully described by your database/migrations directory — good for CI, and it means a missing stub shows up as a missing column or table rather than an install-time error.How can I help you explore Laravel packages today?