shipsaas/laravel-resource-reducer
Laravel Resource Reducer trims API resource payloads so you only return the fields a client actually needs. Define lean, reusable resource classes, cut response size and query overhead, and keep your JSON output consistent across endpoints.
Install and verify discovery first.
composer require shipsaas/laravel-resource-reducer:^1.1
composer validate
php artisan package:discover
v1.1.1 exists specifically to fix a missing composer.json field, so treat it as the first reliable baseline — pin ^1.1.1 rather than ^1.0 and confirm service-provider registration in a clean skeleton before touching a real app. If a config file is publishable, publish and commit it:
php artisan vendor:publish --tag=resource-reducer-config
Where to look first. The repo is small (~46 stars, one recent patch release, terse release notes). Read in this order: README.md → src/ (the trait/helper class and any resolver) → config/ → composer.json (PHP/Laravel constraints and PSR-4 map) → tests/. The description tells you the intent; the source tells you the actual method names, which you should confirm before writing them into controllers.
First use case — one bloated endpoint. Pick your heaviest list endpoint (e.g. GET /api/users returning full UserResource with eager-loaded posts, roles, profile). Compare the JSON payload size and response time before and after applying the reducer to that single resource. This gives you a concrete win to justify rollout and a regression baseline.
// Before: full payload on every call
return UserResource::collection(User::with('posts', 'roles', 'profile')->paginate());
// After: same resource class, trimmed per request (illustrative API)
return UserResource::collection($users)->reduce(
fields: request('fields'), // e.g. "id,name,email"
without: request('without'), // e.g. "posts,profile"
);
Do not refactor existing JsonResource classes on day one. The whole value proposition is that the reducer sits around resources you already have.
Wrap, don't rewrite. Keep UserResource::toArray() as the source of truth and apply reduction at the response boundary. That keeps the resource reusable for internal callers (jobs, exports, other endpoints) while only the HTTP response is trimmed.
Config defaults per resource, overrides per endpoint. The configurable-defaults-with-per-endpoint-overrides model maps naturally to a two-layer setup:
// config/resource-reducer.php (shape will vary — read the real file)
'resources' => [
\App\Http\Resources\UserResource::class => [
'default' => ['id', 'name', 'avatar'],
'without' => ['roles'],
],
],
Then the endpoint only expresses the delta (?fields=id,name,email,bio). This is the pattern that scales: new endpoints inherit sane defaults, and you never repeat allowlists in twenty controllers.
Prefer allowlists over blocklists for public APIs. "Include" semantics fail safe (a newly added model attribute is not exposed by default); "exclude" semantics fail open. Use exclusion for internal/admin endpoints where you trust the resource, inclusion for anything customer-facing.
Sparse fieldsets via query string, but validate them. Treat ?fields= and ?without= as untrusted input. Sanitize against a known key set before passing them to the reducer, and reject or ignore unknown keys explicitly — silent ignoring makes client bugs invisible:
$fields = collect(explode(',', (string) request('fields')))
->intersect(array_keys((new UserResource(null))->getAvailableFields()))
->values();
Nested reduction. The strongest use case is trimming children, not just the top level — a PostResource embedded in a feed usually needs id, title, created_at and nothing else. Define the child reduction on the parent so it applies transitively, rather than asking the client to reach into nested paths.
Relationship dropping is the real performance lever. Reducing top-level keys saves bytes; dropping relationships avoids the with() query entirely if you make the decision before the query runs. Two options:
with() list accordingly (best — actually removes DB work).Pattern 1 is what you should aim for on hot paths:
$with = ['profile'];
if (request()->boolean('with_posts')) {
$with[] = 'posts';
}
$users = User::with($with)->paginate();
Base resource / trait. If you have many resources, add a small abstract ReducibleResource (or a trait) that applies default reduction in toArray() and exposes a reduce() fluent hook. This keeps the call sites consistent and gives you one place to change when the package API moves.
Testing patterns. Assert the contract, not the implementation:
$this->getJson('/api/users?fields=id,name')
->assertOk()
->assertJsonStructure(['data' => [['id', 'name']]])
->assertJsonMissingPath('data.0.email');
Add at least one test per endpoint that hits it with no params (defaults) and one with an aggressive fieldset, so a change to the config default can't silently break clients.
Reduction happens after toArray() — it cannot undo queries. whenLoaded('posts') still fires the eager load and still serializes the sub-resource before your reducer strips it. If response time is the goal, mirror the field/relationship decision into the query builder as above. Measure with a query log, not by eyeballing payload size.
Watch $appends and $hidden. Accessors in $appends run during toArray() regardless of reduction, so a $appends = ['post_count'] accessor that hits the DB will still run on a ?fields=id request unless you conditionally append it. Same hazard class as whenLoaded.
Allowlist key names must match the serialized keys. toArray() keys are the camelCase keys you wrote, not the database columns. Renaming a key in the resource silently breaks a fieldset — add a test that exercises the exact keys.
Nested resources come back as arrays, not resource instances. By the time reduction runs on the parent, PostResource::collection(...) has already resolved into an array (data/links/meta included). If you want to trim nested payloads, configure that on the child resource, not by post-processing the parent's array — otherwise you'll clobber pagination metadata.
Preserve pagination wrappers. A trimmed collection still needs data, links, and meta for clients. If reduced output loses the wrapper, that's a symptom of reducing at the wrong layer — reduce the item, not the collection envelope.
Cache carefully. If you cache responses, key the cache on the fieldset as well as the URL (or canonicalize the query string). Caching a ?fields=id response and serving it to a full-fieldset caller is a classic misfire. And cache the reduced form only if the underlying data has no per-user scoping you'd otherwise get for free.
Config caching will bite you. After publishing or editing the config, php artisan config:cache in deploy. New resource defaults added in code will not appear until the cache is rebuilt — a very confusing "my field isn't showing up" bug.
Be explicit about unknown field behavior. Decide up front: throw a 422 for unknown fields, or ignore. Whichever you choose, document it, because clients will hit it. A 422 is friendlier long-term for a public API.
Debugging a missing field. Work backwards: dump the raw toArray() output, then the reducer input, then the output. Nine times out of ten the key was dropped because it isn't in the config default and wasn't requested — the default allowlist is the most common culprit.
Pin the version and watch the release cadence. Single-maintainer, ~46 stars, one patch release in March 2024. That's fine for a thin serialization helper, but budget for the possibility you outgrow it. Keep the call surface small (one base trait, one config file) so replacing it with a 40-line local implementation is a half-day job, not a refactor.
Extension points to check in src/: any resolver/lookup class you can bind in a service provider (e.g. mapping a model class to a fieldset), custom reducer callbacks, and whether reduction is applied via a trait, a macro on JsonResource, or a standalone service. Binding your own resolver implementation is the cleanest way to load fieldsets from a database or OpenAPI schema rather than the config file.
How can I help you explore Laravel packages today?