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 Saved Filter Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Implementation Patterns

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.

Gotchas and Tips

  • Publishing migrations is mandatory, and 1.10.0 adds a third stub. Re-run 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.
  • The 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.
  • The guards live in the stub, not the migrator. Laravel identifies migrations by full filename including timestamp, so a freshly published stub is a new migration and will execute against an existing schema. Idempotency comes from the 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.
  • An unknown timezone falls back to the app's rather than throwing. That's forgiving at runtime and easy to ship as a bug: a typo'd zone produces a plausible-looking schedule in the wrong place. Validate on write; on read, surface the effective zone (via describeSchedule()) in the UI so the fallback is visible rather than silent.
  • Don't rely on pre-1.10.0 occurrence behaviour. If the report was sent twice on a DST day, that was the bug, not your config. After upgrading, the fix is automatic — but remove any workaround you added (dedupe on sent_at day-truncation, skip-if-already-sent guards keyed on hour) or you risk suppressing legitimate sends.
  • A time the DST change skips never fires that day. 01:30 in Lisbon on the last Sunday of March doesn't exist; occurrences are now computed from the date. Decide whether "skip" is acceptable for that report and tell the user, rather than assuming the package will invent a time.
  • 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.
  • Debugging order when a report misbehaves: (1) does 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).
  • Extension points: the service provider's 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.
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.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core