sinemacula / laravel-api-toolkit
A comprehensive Laravel toolkit for streamlined development of RESTful APIs
Requires
- php: ^8.3
- aws/aws-sdk-php: ^3.342
- illuminate/database: *
- illuminate/http: *
- illuminate/notifications: *
- illuminate/routing: *
- illuminate/support: ^12.9
- illuminate/validation: *
- phpnexus/cwh: ^3.0
- sinemacula/laravel-repositories: ^1.0
- sinemacula/laravel-resource-exporter: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.85
- laravel/framework: ^12.9
- phpstan/phpdoc-parser: ^2.2
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
- squizlabs/php_codesniffer: ^3.13
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-04 17:01:40 UTC
README
The Laravel API Toolkit is a comprehensive package designed to simplify the development of RESTful APIs in Laravel. It provides tools to enhance API functionality, improve error handling, and ensure consistent data output, making API development faster and more reliable.
Features
- Exception Handling: Implements a custom exception handler that captures and formats all exceptions for consistent API error responses, preserving the intended HTTP status codes.
- Queryable Resources: Resource schemas give fine-tuned control over which fields, filters, relations, and orderings are exposed via your API endpoints under a fail-closed allowlist posture, enhancing security and customization.
- Data Repositories: Abstracts database interactions into repositories to promote a cleaner and more maintainable codebase, with safe-by-default deferred writes (failed flushes retain records rather than dropping them) and per-query caching (each query is cached against its own fingerprint, so a cache hit performs zero database queries and a filtered read never returns the full table).
- Data Resources: Schema-driven resources ensure consistent presentation of data across different API endpoints, simplifying client-side data integration.
- Services: A composable service layer with immutable configuration, cross-cutting concerns (transactions, locking), and self-describing results.
Installation
To install the Laravel API Toolkit, run the following command in your project directory:
composer require sinemacula/laravel-api-toolkit
Configuration
After installation, publish the package configuration to customize it according to your needs:
php artisan vendor:publish --provider="SineMacula\ApiToolkit\ApiServiceProvider" --tag=config
This publishes config/api-toolkit.php to your application's config directory. The file is documented inline
and covers exception rendering strategy, sensitive-key redaction, sensitive columns, query parser limits,
deferred write behaviour, middleware toggles, and more. Per-query repository caching is configured in the
sinemacula/laravel-repositories package under repositories.cache.
Usage
API Query Parser
The ApiQueryParser sits behind the ApiQuery facade and is populated automatically by the ParseApiQuery
middleware, which the service provider registers globally by default (controlled via api-toolkit.parser.register_middleware).
Sparse fieldsets - request only the fields you need for a given resource type:
GET /users?fields[user]=id,name,email
use SineMacula\ApiToolkit\Facades\ApiQuery; $fields = ApiQuery::getFields('user'); // ['id', 'name', 'email']
Filtering - apply column-level filters by passing a URL-encoded JSON object of operator tokens:
GET /users?filters={"status":{"$eq":"active"},"created_at":{"$ge":"2024-01-01"}}
The filters value must be a JSON string; requests carrying a non-JSON value are rejected with a validation
error.
Available built-in operator tokens: $eq, $neq, $gt, $lt, $ge, $le, $in, $between,
$contains, $null, $notNull. Each is accepted only on a column whose declared capability answers it,
so a request pairing an operator with a column that cannot serve it is rejected with a 422 naming both and
listing the operators that column does accept. The bare shorthand {"name":"Alice"} is the $eq operator
written without its token, and is held to the same declaration.
Free-text search - match a term against the fields a resource declares searchable:
GET /users?search=smith
The term is matched against the columns of the requested resource only; it never traverses a relation,
because a text predicate inside a relation subquery is paid once per candidate row. Each searchable field
declares the match shape it is served with - an exact match, a prefix match, or an anywhere-match - and
the connection's registered search driver refuses a shape it cannot serve from an index rather than
scanning the table. Terms are bounded by api-toolkit.search; one outside the bounds is rejected with a
422 rather than trimmed to fit.
The shortest word accepted is three characters, and configuration may raise that floor but never lower it. It is measured rather than chosen: below three, MySQL matches nothing at all once the word is shorter than the index token size, and PostgreSQL answers correctly but by reading the whole table. The floor is applied to every word rather than to the term as a whole, because a word beneath it is dropped from a full-text phrase - widening the match - while a pattern comparison keeps it, so the two engines would answer the same request with different rows. Both failures are invisible in the response, which is the hazard the parameter exists to close.
Drivers ship for MySQL, PostgreSQL, and SQLite, registered against the names those connections report. MariaDB reports its own name and has no n-gram parser, so it is not among them and a search on such a connection fails until you register a driver for it yourself. The index each declaration is served from belongs to your own migration:
- On MySQL, the columns declared for an anywhere-match are matched together through a single
MATCH, which needs oneFULLTEXTindex over exactly that column list, createdWITH PARSER ngram. A full-text match OR-ed with any other predicate loses the full-text access path and reads the whole table, so an anywhere-match may not be declared beside another strategy on this connection; the driver refuses that combination rather than serving it as a scan. - On PostgreSQL, a prefix match and an anywhere-match each need a
gin_trgm_opsindex over their own column, and thepg_trgmextension installed. Several columns may be declared under different strategies, because the planner combines the index scans behind a disjunction. - An exact match needs an ordinary index leading with the column on either engine.
The proof is read twice. php artisan api-toolkit:validate-schemas reports a declaration with no index
behind it, which is the cheapest place to find one - run it in CI. Because schema validation is disabled in
production by default, the same proof is also taken on the first search each worker process serves and
memoised from there, so a missing index refuses the request instead of quietly reading the table: on
PostgreSQL a missing trigram index is not an error at all, and the search would otherwise return the right
rows out of a sequential scan for as long as the index stayed missing.
Sorting - sort by one or more columns, with optional direction:
GET /users?order=last_name,first_name:desc
Page-size ceiling - a client-supplied ?limit above api-toolkit.parser.max_limit (default 100) is
rejected with a 422 naming the ceiling and the size asked for, rather than reduced to the ceiling. A page
quietly shortened cannot be told apart from the end of the result set. Set the ceiling to 0 to disable it:
GET /users?limit=200 // 422, the ceiling is 100 GET /users?limit=25 // honoured as-is
Relation aggregates - request counts, sums, or averages over declared relations:
GET /users?counts[user]=memberships,posts GET /accounts?sums[account][transaction]=amount GET /accounts?averages[account][order]=total
ApiQuery::getCounts('user'); // ['memberships', 'posts'] ApiQuery::getSums('account'); // ['transaction' => ['amount']] ApiQuery::getAverages('account'); // ['order' => ['total']]
Cursor-based pagination is enabled by adding ?pagination=cursor (or including a ?cursor token); offset
pagination is used otherwise.
Schema-Driven ApiResource
Extend ApiResource and declare a schema() method using the Field, Relation, Count, Sum, and Average
schema helpers. The compiled schema drives field resolution, guard evaluation, and eager-load planning automatically.
use App\Models\User; use SineMacula\ApiToolkit\Attributes\ForModel; use SineMacula\ApiToolkit\Enums\Capability; use SineMacula\ApiToolkit\Enums\SearchStrategy; use SineMacula\ApiToolkit\Http\Resources\ApiResource; use SineMacula\ApiToolkit\Schema\Count; use SineMacula\ApiToolkit\Schema\Field; use SineMacula\ApiToolkit\Schema\Relation; use SineMacula\ApiToolkit\Schema\Sum; #[ForModel(User::class)] class UserResource extends ApiResource { const RESOURCE_TYPE = 'user'; protected static array $default = ['id', 'name', 'email', 'created_at']; public static function schema(): array { return Field::set( Field::scalar('name')->filterable(Capability::EXACT)->sortable()->searchable(SearchStrategy::SUBSTRING), Field::scalar('email')->filterable(Capability::EXACT)->searchable(SearchStrategy::SUBSTRING), Field::timestamp('created_at')->sortable(), Field::compute('full_name', 'getFullName'), Relation::to('organization', OrganizationResource::class)->traversable(), Count::of('memberships'), Sum::of('orders', 'total'), ); } }
Fields marked filterable() or sortable() are the only ones a client may query, and a field marked
searchable() is the only one the ?search= term is matched against - the strategy it is given decides both
how the value is matched and which index has to back the column. Relations marked traversable() can be
targeted by nested filters. The $default static property declares the fields returned when the client sends
no ?fields parameter.
Query capabilities - filterable() takes the access path the column is declared to have, and that
capability decides which filter operators the column answers. An operator outside its row is refused with a
422 rather than served by a scan:
| Capability | Declare it for | Operators the column answers |
|---|---|---|
Capability::EXACT |
a keyed column read by equality - an id, a foreign key, an email | $eq, $in, $null, $notNull |
Capability::ENUM |
a small closed set, so the complement of one value stays bounded | $eq, $in, $neq, $null, $notNull |
Capability::RANGE |
an ordered column - a number, a date, a timestamp | $eq, $in, $gt, $ge, $lt, $le, $between, $null, $notNull |
Capability::DOCUMENT |
a JSON column behind a containment index | $contains |
Capability::OPAQUE |
a column with no access path the resource will vouch for | $eq |
$neq reaches only the closed set, because the complement of one value spans nearly the whole index
everywhere else; $contains reaches only the document, whose column is the only one an inverted index backs;
and the nullity pair travels with every case carrying a B-tree, since both halves read one contiguous
partition of it. A token no row names was registered by your own application against the operator registry,
and the pairing is left to the application that made both halves of it.
Model binding and discovery - the #[ForModel(...)] attribute binds a resource to its model, and may be
repeated to bind one resource to several models. By default (api-toolkit.resources.paths left null) resources
are discovered at boot from the application's own Http/Resources directory plus each module's, resolved from
app_path() so a modular application is covered with no configuration, and compiled into the model-to-resource
map automatically so no central map needs maintaining. Set paths to an explicit array to override the scanned
roots, or an empty array to disable discovery. An explicit api-toolkit.resources.resource_map entry always
wins over a discovered binding - use it as the canonical-resource tiebreak when a model has more than one
resource, or to bind resources living outside the scanned paths. When two discovered resources claim the same model and no
explicit entry resolves it, the first (in sorted file order) wins and a warning is logged; with
validate_schemas enabled the conflict fails the boot instead.
Eager-load planning - the resource builds with()/withCount()/withSum()/withAvg() maps from the
resolved field set, so relations are loaded precisely and automatically:
// Build a with() map for the active field set $with = UserResource::eagerLoadMapFor(UserResource::resolveFields());
Field-set control at instantiation:
new UserResource($user, loadMissing: true); // eager-loads missing relations new UserResource($user, included: ['id', 'email']); // explicit field set new UserResource($user, excluded: ['email']); // field set minus exclusions (new UserResource($user))->withAll(); // all schema fields
Schema validation - api-toolkit.resources.validate_schemas has all registered schemas validated
during application boot. It defaults to enabled outside production (set VALIDATE_SCHEMAS=false to opt
out) and off in production, where the boot cost is not worth paying. The api-toolkit:validate-schemas
Artisan command runs the same validation on demand - independently of the flag - so it can also gate CI.
What validation proves about the query surface:
- every
filterable()andsortable()declaration names a column that exists. A computed field and an accessor reading a different path are answered from the schema alone; the rest is answered by the table's own column listing. - an ordered index leads with every
sortable()column, unless the field carries anindexed()orunindexed()override. - an index the connection carries serves every
searchable()declaration, in the shape the declared strategy needs. - no declaration names a column configured as sensitive.
The checks that read the connection stay silent where it cannot be read - a boot with no database behind it, or one whose migrations have not run, proves nothing either way rather than failing.
Reading the index catalogue belongs to validation: no filter and no sort asks the connection about an index while a request is served. Two reads of schema metadata do sit on the request path, both memoised after the first: column narrowing intersects its projection with the table's column listing, and - where validation is disabled, as it is in production by default - the search surface takes its index proof on the first search a worker serves.
Repositories
Extend ApiRepository to get a repository wired to the API query parser, eager-load planning, and
pagination out of the box:
use SineMacula\ApiToolkit\Repositories\ApiRepository; class UserRepository extends ApiRepository { public function model(): string { return User::class; } }
Call withApiCriteria() before any read to apply the parsed filters, sorts, eager loads, and limit from the
current request automatically:
$users = $repository->withApiCriteria()->paginate();
Allowlist posture - only schema fields declared filterable(), sortable(), or traversable() are
accepted, and every undeclared key is rejected with a validation error. There is no opt-out: a resource that
declares nothing is queryable by nothing. A filterable declaration also carries the capability the column is
queried through, so the surface records not just which columns may be filtered but how, and each filter is
gated as a (column, operator) pair rather than as a column alone. The refusal is decided from the compiled
declaration before the operator handler is resolved, so no statement is issued for a query that will be
refused.
Sensitive columns - the columns listed in api-toolkit.resources.sensitive_columns may never be declared
filterable(), sortable(), or searchable(). Schema validation refuses a resource that declares one, so a
credential or verification column cannot become an oracle a client narrows on without ever reading the value.
The default covers the stock Laravel and Fortify auth column family.
Index backing - a sortable() declaration offers to order the whole table by that column on request, so
schema validation asks the connection whether an index leads with it. Leading is the whole test: a column named
second in a composite index is covered by that index and still cannot be ordered by on its own, so checking
mere membership would pass exactly the declaration the database cannot serve. Where the connection names index
kinds, only a kind that holds an order counts, so a full-text or trigram index over a column does not make it
sortable. A connection that cannot be inspected at all reports nothing rather than reporting nothing found, so
booting with no database behind the application skips the check instead of failing it. The catalogue is read
during validation and never while a sort is served.
Two narrow overrides exist for what reading the catalogue cannot show. indexed('users_lower_name_index')
names the index behind the column - an expression or partial index, say. The name is looked up on the
connection, so naming an index the table does not carry is itself a defect; what that index covers is not read
back, since the catalogue cannot describe the expression behind it, so the override vouches for the column
rather than proving it. unindexed('the table is bounded at a few hundred rows') records a deliberate
exemption, and the reason is required so an exemption is never silent.
Random ordering - ?order=random is disabled by default because it sorts the whole table to return one
page. Enable it with api-toolkit.repositories.allow_random_order (API_TOOLKIT_ALLOW_RANDOM_ORDER=true);
while it is disabled the keyword is treated as an ordinary sort key and rejected like any undeclared column.
Query cost caps - a request whose parts are each cheap and each declared can still multiply into an
expensive query. The api-toolkit.query_cost caps bound the filter document's size and nesting, the keys and
value-list items it dispatches, the sort keys and relation aggregates it asks for, and how deep it pages. A
request over a cap is rejected before any SQL is issued, with a 422 naming the parameter, the position
within it, the cap, the limit, and the value supplied, so the client can correct the query itself. Every cap
is tunable, and setting one to 0 disables it.
Cacheable trait - add per-query transparent caching to any ApiRepository subclass:
use SineMacula\Repositories\Concerns\Cacheable; class UserRepository extends ApiRepository { use Cacheable; protected int $cacheTtl = 3600; protected ?string $cacheStoreName = null; // uses app default protected bool $cacheReferenceTable = false; // whole-table reference mode }
Read results are keyed by query fingerprint; write operations invalidate the table automatically. Call
withoutCache() to bypass the cache for a single read, or flushCache() to invalidate immediately.
ReferenceCache is enabled by setting protected bool $cacheReferenceTable = true on a Cacheable
repository. In reference mode the full table is loaded once and memoised in-process; single-record lookups
resolve in O(1) without touching the database. Use this only for small, rarely-changing lookup tables.
Deferrable trait - buffer insert operations in memory and flush them as bulk INSERT statements at the
lifecycle boundary (RequestHandled, CommandFinished, JobProcessed, or JobFailed):
use SineMacula\ApiToolkit\Repositories\Concerns\Deferrable; class AuditRepository extends ApiRepository { use Deferrable; } // In your service: $auditRepository->defer(['user_id' => 1, 'action' => 'login']);
The default on_failure = 'collect' strategy retains failed records for the next boundary flush rather than
dropping them. The 'throw' strategy raises a WritePoolFlushException for callers that own an explicit flush
site. The 'log' strategy is best-effort only - use it solely for genuinely disposable writes such as
telemetry.
The WritePool is a scoped singleton, so after a deferred write you can call
app(WritePool::class)->lastAutoFlushResult() to observe the outcome of the most recent automatic flush
(null if none has occurred). Under the non-throwing 'collect' and 'log' strategies this accessor is the
only in-process signal that an auto-flush failed.
Filter-Operator Registry
The OperatorRegistry singleton maps token strings to FilterOperator handler instances. Register custom
operators in a service provider's boot() method:
use SineMacula\ApiToolkit\Repositories\Criteria\OperatorRegistry; use SineMacula\ApiToolkit\Contracts\FilterOperator; class AppServiceProvider extends ServiceProvider { public function boot(OperatorRegistry $registry): void { $registry->register('$regex', new RegexOperator); // Override an existing operator $registry->override('$contains', new StrictContainsOperator); } }
A FilterOperator is any class implementing SineMacula\ApiToolkit\Contracts\FilterOperator, or a closure
with the same signature. Operators registered via register() throw InvalidArgumentException if the token
is already taken; use override() to replace unconditionally.
An operator that fans a single value out into one predicate per item should also implement
SineMacula\ApiToolkit\Contracts\ExpandsValueList and report that item count from countValueItems(). The
dispatcher measures the reported count against the max_in_items cap, so a list spelled as a delimited
string is bounded the same way as one spelled as an array. An operator that does not implement the contract
is measured as one item per non-list value.
The capability matrix governs the operators the package ships and whose SQL it wrote, so a token registered
under a name none of them uses is not held to it and applies on any declared column. The package cannot say
which access path such a token needs, and refusing it would leave register() an extension point able to
produce only tokens every column rejects. A token that no one registered is not an operator at all: it is
read as a column name and rejected by the allowlist. Overriding a shipped token keeps that token's place in
the matrix, so replacing the $contains handler leaves it served from document columns alone.
Exception Handling
Register the exception handler once in bootstrap/app.php:
use SineMacula\ApiToolkit\Exceptions\ApiExceptionHandler; ->withExceptions(function (Exceptions $exceptions): void { ApiExceptionHandler::handles($exceptions); })
All exceptions are mapped to typed ApiException subclasses and rendered as consistent JSON error responses
with appropriate HTTP status codes. The rendering strategy is configurable:
'auto'(default) - renders JSON unless the request does not expect JSON and debug mode is on.'always_json'- always renders JSON.'json_when_expected'- renders JSON only when the request expects a JSON response (Laravel'sexpectsJson(), e.g.Accept: application/json).
Sensitive-key redaction - request data written to the exception log is automatically scanned and values
whose keys match any substring in api-toolkit.exceptions.sensitive_keys (default: password, token,
secret, authorization) are replaced with [redacted] before being written to the log. Add application-specific
keys to that array in your published config to extend coverage.
Middleware
The service provider registers the following middleware automatically. Each registration can be disabled
independently in api-toolkit.middleware:
maintenance_mode_swap- JSON503maintenance responses with anexceptURI allowlist.json_pretty_print- opt-in pretty-printed JSON responses via a query parameter.throttle- API-friendly rate-limit responses; auto-selects the Redis variant when Redis is the cache driver.
json_pretty_print accepts 'scope': 'global' (default, pushed to the global stack) or 'scope': 'api'
(appended to the api middleware group only). maintenance_mode_swap is always prepended to the global
stack when enabled, and throttle is registered as the router's throttle alias, so neither takes a scope.
Typed request capabilities (soft-delete visibility via includeTrashed() / onlyTrashed()) are available
on demand through SineMacula\ApiToolkit\Http\RequestCapabilities::fromRequest($request), which resolves
and caches them lazily on first access - no middleware registration is required.
Request throttling and rate-limit keying - each request is keyed by method, host, path, and caller
identity. Authenticated requests are keyed by the user identifier; guests are keyed by their client IP
($request->ip()), matching Laravel's stock ThrottleRequests. Guests are deliberately not pooled into a
single shared bucket, which would let one anonymous caller exhaust the rate limit for every other guest.
Behind a shared-IP proxy, load balancer, CDN, or NAT, per-IP guest keying can over-throttle many distinct
callers that share one egress IP. Configure Laravel's TrustProxies middleware so that $request->ip()
resolves the real client IP rather than the proxy's.
To key guests by an application-specific identifier (for example an API key) instead of their IP, set
api-toolkit.middleware.throttle.class to your own middleware that uses the ThrottleRequestsTrait and
overrides resolveRequestSignature(). That config option is the supported customisation point.
Schema Introspection and OpenAPI Export
The schema compiler resolves filterable columns, sortable columns, traversable relations, and all field keys
for any registered resource without instantiating it. A complementary database-schema introspector resolves
model columns, their type and nullability, and relations; it is used internally by ApiCriteria and is
available for injection:
use SineMacula\ApiToolkit\Contracts\SchemaIntrospectionProvider; public function __construct(private SchemaIntrospectionProvider $introspector) {}
An OpenAPI 3.1 components document can be generated from the registered resource map and operator grammar:
php artisan api-toolkit:export-openapi php artisan api-toolkit:export-openapi --output=openapi.json
Upgrading
See UPGRADE.md for version-by-version migration guides, including breaking changes and the steps required to move from 1.x to 2.x.
Requirements
- PHP ^8.3
- Laravel 12+
Testing
composer test
composer test:coverage
composer check
composer format
composer smells
Changelog
See CHANGELOG.md for a list of notable changes, and UPGRADE.md for version upgrade guides.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md for guidelines on branching, commits, code quality, and pull requests.
Security
If you discover a security vulnerability, please report it responsibly. See SECURITY.md for the disclosure policy and contact details.
License
Licensed under the Apache License, Version 2.0.