horlyk/fetch-xml-bundle
A Symfony bundle for fetching and parsing XML feeds, simplifying integration with external XML sources in your PHP applications.
This package adds declarative, request-driven filtering on top of Eloquent query builders. Day-to-day it means: define which filters an endpoint accepts, map them to columns or relations, and let the package translate ?filter[...] query strings into a constrained query.
composer require <vendor>/<package>
php artisan vendor:publish --tag=<package>-config # only if you need to change defaults
// app/Http/Controllers/PostController.php
public function index()
{
return PostResource::collection(
QueryBuilder::for(Post::class)
->allowedFilters(['status', 'title'])
->allowedSorts(['created_at'])
->paginate()
);
}
Hit it with ?filter[status]=active and confirm the generated SQL via ->toSql() or DB::listen() before wiring up the frontend.Validate the happy path on a model with one relation before rolling the pattern out across the app — relation filtering is the part that changes shape the most between versions.
Centralise filter definitions per resource, not per controller. Keep a filter class / allowed-filters method next to the model or in app/Queries, so HTTP verb and filter vocabulary stay in sync:
class PostFilters
{
public static function all(): array
{
return [
'status', // direct column
AllowedFilter::exact('author'), // relation
AllowedFilter::scope('published'),
];
}
}
Use aliases as the public API contract. The v1.5.0 alias support for relations is the main lever for decoupling your frontend from your schema. Alias to the internal path, not the other way around:
AllowedFilter::exact('state', 'status'), // column rename safety
AllowedFilter::exact('author', 'author.name'), // relation alias
AllowedFilter::exact('writer', 'author.name'), // second public name, same target
This lets you rename status → state, or move author.name behind a join, without breaking clients. Treat alias keys as versioned API surface: document them alongside the endpoint.
Filter on relations deliberately. Decide between whereHas semantics (filter parent by existence of a matching child — usually what you want for ?filter[author]=jane) and join-based filtering (needed for sorting by a related column). Sorting and filtering on the same relation is the common integration point that requires a join; add it explicitly rather than relying on the filter to do it.
Lean on empty-value handling rather than frontend guards. Since the v1.5.0 bugfix, empty filter values are ignored instead of applied. That means a select box with <option value="">All</option> can submit ?filter[status]= safely. Remove the client-side "strip empty params" helpers you may have written — but only after checking each endpoint's behaviour (see Gotchas).
Compose with the rest of the query pipeline. Standard order that stays predictable: filters → scopes → sorts → eager loads → pagination. Add ->allowedIncludes([...]) for relation loading separately from filtering so ?include=author doesn't double as an implicit filter.
Test with SQL assertions, not just JSON. Feature tests will pass even when a filter is silently dropped. Assert the query either via DB::listen() or by seeding rows that differ only in the filtered field:
$this->getJson('/api/posts?filter[writer]=jane')
->assertJsonCount(2, 'data');
Include a test per alias, since alias mapping failures are silent (the filter just never matches).
?filter[title]= no longer filters for an empty string. If any endpoint intentionally filters for blank/null (?filter[deleted_at]=) you now need an explicit scope or AllowedFilter::scope('trashed'). Grep your clients for filters that can legitimately be empty.->toSql() and Log::info on the query builder during development.allowedFilters() should reach the query; anything else should be dropped or 400'd. Never feed raw request arrays into the builder — that's how aliases become an injection-adjacent footgun.php artisan config:clear / config:cache. A stale cached config is a classic "works locally" cause when toggling the package's feature flags.whereHas subqueries by default; for large tables combine with indexes on the foreign key and consider a join-based custom filter for hot endpoints. Check with ->explain() on the real query, not the test fixture.QueryBuilder::for(...)->allowedFilters(...)->tap(fn ($q) => logger($q->toSql(), $q->getBindings())), or listen globally in a service provider while investigating. Compare the SQL against the raw Post::where(...) you expected.AllowedFilter::custom() / custom filter class over modifying package internals; keep custom filters in one namespace so a future breaking release touches a single directory. Scopes are the least fragile escape hatch because they survive filter API changes.composer.json before deploying. Minor releases in the 1.x line have changed filter-value semantics, so re-run your full filter test suite — not just the new feature tests — after composer update.How can I help you explore Laravel packages today?