devespresso / laravel-api-kit
A Laravel API kit providing filtering, transformation, repositories, request validation, and authorisation.
Package info
github.com/devespressostudio/laravel-api-kit
Type:project
pkg:composer/devespresso/laravel-api-kit
Requires
- php: ^8.1
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0
README
A Laravel package that provides a complete data filtering, transformation, and API response system. Drop it into any Laravel application to get automatic query filtering, model transformation, pagination, sorting, authorisation, and CRUD repositories — all driven by simple class conventions.
Requirements
- PHP 8.1+
- Laravel 10, 11, 12, or 13
Installation
composer require devespresso/laravel-api-kit
Publish the config file:
php artisan vendor:publish --provider="Devespresso\LaravelApiKit\LaravelApiKitServiceProvider"
Scaffolding
Generate a full API resource with a single command:
php artisan devespresso:api-kit:scaffold Post
This creates all 7 components at once:
| Component | Generated Class |
|---|---|
| Model | App\Models\Post |
| Repository | App\Repositories\PostRepository |
| Controller | App\Http\Controllers\PostController |
| Transformer | App\Transformers\PostTransformer |
| Request | App\Http\Requests\PostRequest |
| Authorisation | App\Services\Authorisation\PostAuthorisationService |
| Filter Service | App\Services\Filters\PostFilterService |
All paths are driven by the paths config — if you customise them, the scaffold command follows automatically.
Options
# Only generate specific components php artisan devespresso:api-kit:scaffold Post --only=model,repository,transformer # Skip specific components php artisan devespresso:api-kit:scaffold Post --except=model # Overwrite existing files php artisan devespresso:api-kit:scaffold Post --force
Available component names for --only and --except: model, repository, controller, transformer, request, authorisation, filter-service.
Configuration
Publish the config file to config/devespressoApi.php:
php artisan vendor:publish --provider="Devespresso\LaravelApiKit\LaravelApiKitServiceProvider"
pagination.with_pages
Controls the default pagination method used when no pagination_type is passed in the request.
'pagination' => [ 'with_pages' => false, // false = simplePaginate() | true = paginate() (includes total count) ],
paths
Namespaces used to auto-resolve classes and to determine where the scaffold command places generated files. Change these if your project uses a non-standard structure.
'paths' => [ 'models' => 'App\\Models\\', 'transformers' => 'App\\Transformers\\', 'repositories' => 'App\\Repositories\\', 'controllers' => 'App\\Http\\Controllers\\', 'requests' => 'App\\Http\\Requests\\', 'authorisation' => 'App\\Services\\Authorisation\\', 'filter_services' => 'App\\Services\\Filters\\', ],
auto_select
When true, the filter service reads the active transformer format and automatically adds a SELECT clause to the query — only fetching columns that are actually needed. Prevents SELECT * without any manual effort.
'auto_select' => true,
Set to false to let Eloquent fall back to SELECT *, or when you need full manual control over selected columns.
auto_eager_load
When true, any relation defined as a nested array in the transformer format is automatically eager-loaded with its own scoped SELECT. Eliminates N+1 queries without writing ->with() manually.
'auto_eager_load' => true,
Set to false to manage eager loading manually in your filter service or controller.
enable_explicit_filtering
When true, the filter service only dispatches request keys that are explicitly listed via the explicitFilters parameter. Keys not in the list are silently ignored. sort and search are always exempt. $autoApply is unaffected.
'enable_explicit_filtering' => false,
See Explicit Filtering for usage.
roles, numeric_roles, role_resolver
Controls role-based method restrictions in the filter service.
// Invokable class that returns the current user's role (string or int, or null) 'role_resolver' => App\Support\RoleResolver::class, // String roles — ordered lowest to highest 'roles' => ['moderator', 'editor', 'admin'], // OR — numeric roles, no list needed 'numeric_roles' => false,
role_resolvermust be an invokable class — closures cannot be used because the config file must be cacheable (php artisan config:cache).
See $roleMethods for usage.
transformers.prefixes
Single-character prefixes used in transformer $formats arrays to control how attributes are treated. All three are fully configurable — if any clash with your attribute names, change them here and the entire package will use your values automatically.
'transformers' => [ 'prefixes' => [ 'hidden_attributes' => '!', // selected from DB but excluded from the JSON response 'custom_attributes' => '@', // computed via a transformer method, not read from the DB 'accessor_attributes'=> '~', // Laravel model accessor — not selected from DB, but included in output 'unmerged_format' => '_', // format key that is returned as-is, not merged with '*' ], ],
| Key | Default | Effect |
|---|---|---|
hidden_attributes |
! |
Attribute is SELECTed but stripped from the response |
custom_attributes |
@ |
Attribute value is resolved via $customAttributes map |
accessor_attributes |
~ |
Attribute is a Laravel model accessor — NOT added to SELECT, but included in the output via $model->attribute |
unmerged_format |
_ |
Format key is not merged with the * wildcard format |
Core Components
1. EnableDatabaseFiltering Trait
Add to any Eloquent model to enable filtering:
use Devespresso\LaravelApiKit\Traits\EnableDatabaseFiltering; class Post extends Model { use EnableDatabaseFiltering; protected $defaultFilterService = PostFilterService::class; // optional protected $searchableColumns = ['title', 'body']; // used by the search scope }
Call filter() from a controller:
$posts = Post::filter($request->validated(), $request->user());
filter() accepts two optional extra parameters:
Post::filter( data: $request->validated(), // drives filter methods and sorting user: $request->user(), // available via $this->user and getEffectiveRoles() query: $query, // pre-scoped Builder — base constraints before filters run extras: $extras, // arbitrary context — read via $this->getExtraProperty('key') );
Pre-scoping the query with a parent resource ($query):
// Only show posts belonging to the current team — enforced before filters run $query = Post::where('team_id', $team->id); $posts = Post::filter($request->validated(), $request->user(), query: $query);
Passing context into filter methods ($extras):
$posts = Post::filter( $request->validated(), $request->user(), extras: ['team' => $team] ); // Inside PostFilterService — read the extra value via getExtraProperty(): public function setConditions(): void { $team = $this->getExtraProperty('team'); $this->query->where('visibility', $team->default_visibility); }
Using $this->user inside the filter service:
$this->user holds the authenticated user passed as the second argument to filter(). It is available anywhere in the filter service — setConditions(), filter methods, and any custom method you add to the subclass.
public function setConditions(): void { // Scope results to the authenticated user $this->query->where('user_id', $this->user->id); } public function status(string $value): void { // Only admins can filter by draft status if ($value === 'draft' && !in_array('admin', $this->getEffectiveRoles())) { return; } $this->query->where('status', $value); }
2. BaseFilterService
Create a filter service per model by extending BaseFilterService. Each key in the incoming request data is camelCased and dispatched to a matching method on the service.
use Devespresso\LaravelApiKit\Services\Filters\BaseFilterService; class PostFilterService extends BaseFilterService { // Columns users are allowed to sort by protected $sortColumns = ['created_at', 'updated_at', 'id', 'title']; // Alias => real column name mappings for sort protected $customSortColumns = ['date' => 'created_at']; // Default sort when no 'sort' key is in the request protected $defaultSortingColumn = ['created_at,desc']; // Methods that cannot be triggered by request data protected $guardedMethods = ['sensitiveMethod']; // Methods restricted by role protected $roleMethods = ['admin' => ['includeTrashed']]; // Methods always applied, regardless of request data protected $autoApply = ['onlyPublished' => true]; // Baseline constraints always added to the query protected function setConditions(): void { $this->query->where('team_id', $this->user->team_id); } // Called when request data contains 'status' public function status(string $value): void { $this->query->where('status', $value); } // Called when request data contains 'author_id' public function authorId(int $value): void { $this->query->where('user_id', $value); } // Always applied via $autoApply public function onlyPublished(bool $value): void { $this->query->where('published', true); } // Only callable by users with the 'admin' role public function includeTrashed(bool $value): void { if ($value) { $this->query->withTrashed(); } } }
Available helpers inside filter methods
$this->getDataValue('key', $default); // get a value from request data $this->dataHasValue('key', 'value'); // check if key equals a specific value $this->dataHasKeys(['key1', 'key2']); // check all keys are present $this->getExtraProperty('tenant_id'); // get a value from $extras $this->with(['comments', 'tags']); // eager load relations $this->withCount(['comments']); // eager load relation counts $this->disableConditions(); // skip setConditions() for the next filter() call — useful in admin or internal contexts $this->setSelect(['id', 'title']); // override the auto-selected columns; has no effect when auto_select is disabled
Method Dispatch and Security
The filter service works by taking each key in the incoming request data, converting it to camelCase, and calling the matching public method on the service if it exists. For example, passing author_id=5 in the request will automatically call $this->authorId(5).
This means any public method on your filter service subclass is callable from request data by default. The package protects against this in four ways:
1. Base class methods are always blocked
All public methods defined on BaseFilterService itself (e.g. setData, setQuery, filter) are automatically guarded and can never be triggered by request data. sort and search are intentionally excluded from this list so they remain dispatchable.
2. Protected methods are automatically blocked
Only public methods can be dispatched. If you define a method as protected on your subclass, it will never be triggered by request data — no configuration needed. Use this as a natural way to write internal helper methods without worrying about accidental exposure:
protected function applyTeamScope(): void { // safe — cannot be triggered from request data $this->query->where('team_id', $this->user->team_id); }
3. $guardedMethods — block specific public methods on your subclass
Use this to explicitly prevent public methods on your subclass from being triggered by request data:
protected $guardedMethods = ['internalScope', 'sensitiveMethod'];
Any method listed here will be silently skipped even if a matching key is present in the request.
4. $roleMethods — restrict methods to specific roles
Maps role names to the methods that require them. A method is only dispatched if the current user holds that role — or a higher one:
protected $roleMethods = [ 'moderator' => ['includeArchived'], 'editor' => ['includeUnpublished'], 'admin' => ['includeTrashed', 'byAnyTeam'], ];
Roles are hierarchical — a higher role automatically inherits access to all methods available to lower roles. Declare the hierarchy and a resolver in config/devespressoApi.php:
'roles' => ['moderator', 'editor', 'admin'], // ordered lowest to highest 'role_resolver' => App\Support\RoleResolver::class,
The resolver must be an invokable class — closures are not supported because the config file must be cacheable:
// app/Support/RoleResolver.php class RoleResolver { public function __invoke(?Authenticatable $user): mixed { return $user?->role; // e.g. 'admin', 'editor', 'moderator', or null } }
An admin user can trigger methods listed under admin, editor, and moderator. An editor can trigger editor and moderator methods, but not admin.
Full example:
// app/Services/Filters/PostFilterService.php class PostFilterService extends BaseFilterService { protected $roleMethods = [ 'moderator' => ['includeArchived'], 'editor' => ['includeUnpublished'], 'admin' => ['includeTrashed', 'byAnyTeam'], ]; // Accessible to moderators and above public function includeArchived(bool $value): void { $this->query->withoutGlobalScope('active'); } // Accessible to editors and above public function includeUnpublished(bool $value): void { $this->query->where('published', false); } // Admin only public function includeTrashed(bool $value): void { $this->query->withTrashed(); } public function byAnyTeam(int $teamId): void { $this->query->where('team_id', $teamId); } }
Calling from a controller requires no extra work — role checking happens automatically:
$posts = Post::filter($request->validated(), $request->user());
Numeric roles — if your roles are numeric (e.g. 1, 2, 3 or 10, 20, 30), set numeric_roles to true and skip the roles list entirely. The hierarchy is derived automatically from the keys in $roleMethods:
'role_resolver' => App\Support\RoleResolver::class, 'numeric_roles' => true,
protected $roleMethods = [ 1 => ['includeArchived'], 2 => ['includeUnpublished'], 3 => ['includeTrashed', 'byAnyTeam'], ];
A user with role 3 can trigger methods at levels 1, 2, and 3. A user with role 1 can only trigger level 1 methods.
That's all the setup needed — no overrides required on individual filter services.
If you need custom resolution logic for a specific service, override getEffectiveRoles():
protected function getEffectiveRoles(): array { // custom logic — return the expanded set of roles yourself return $this->user?->getAllGrantedRoles() ?? []; }
Rule of thumb: keep internal helpers
protected. If a public method should not be triggerable from a request key, add it to$guardedMethods. If it should only be available to specific roles, add it to$roleMethods.
Auto-Apply
Methods listed in $autoApply are always dispatched regardless of what is in the request data. They run after the request-driven filters and cannot be skipped by the caller:
protected $autoApply = ['onlyPublished' => true]; public function onlyPublished(bool $value): void { $this->query->where('published', true); }
Use this for constraints that must always be enforced — scoping to active records, filtering by tenant, etc.
Explicit Filtering
For an extra layer of security, you can restrict which request keys are allowed to drive filter methods on a per-call basis. This is controlled by two things:
- Config flag — enable it globally in
config/devespressoApi.php:
'enable_explicit_filtering' => true,
- Allowed list per request — pass it through the model's
filter()call:
$posts = Post::filter( $request->validated(), $request->user(), explicitFilters: ['status', 'author_id'] );
When enable_explicit_filtering is true, the restriction always applies — there is no opt-out per call. Only keys in the allowed list are dispatched to filter methods; anything not listed is silently ignored, even if a matching public method exists. sort and search are always exempt.
Not passing an allowed list is treated as an empty list — all request-driven filters are blocked. This means every endpoint that uses filtering must explicitly declare which keys it allows:
// Inside a controller or repository — restrict to safe filters for this endpoint $posts = Post::filter( $request->validated(), $request->user(), explicitFilters: ['status', 'category_id', 'published'] );
$autoApplymethods are unaffected — they always run regardless of the explicit filter list.
Pagination
Control pagination via request data:
pagination_type |
Result |
|---|---|
| (not set) | simplePaginate() or paginate() based on config |
simple |
simplePaginate() — no total count query |
paginate |
paginate() — includes total count |
none |
get() — returns all results |
GET /posts?pagination_type=none&per_page=50
Sorting
GET /posts?sort=created_at,desc
GET /posts?sort[]=title,asc&sort[]=created_at,desc
Allowed sort columns are controlled by $sortColumns. You can also define aliases via $customSortColumns:
protected $sortColumns = ['created_at', 'updated_at', 'id', 'title']; // 'date' in the request maps to 'created_at' on the query protected $customSortColumns = ['date' => 'created_at'];
For complex sorts that can't be expressed as a simple column — such as FIELD(), COALESCE(), or any raw SQL expression — use $rawSort to map an alias to a method on your filter service:
protected $rawSort = ['status_order' => 'sortByStatus']; protected function sortByStatus(): string { return "FIELD(status, 'active', 'pending', 'closed')"; }
GET /posts?sort=status_order,asc
The method returns the raw SQL expression — no need to handle the direction. The framework appends it and calls orderByRaw() for you. Raw sort methods bypass the column allowlist entirely.
Methods listed in
$rawSortare automatically guarded from request data dispatch — they cannot be triggered as filter methods regardless of their visibility.
3. BaseTransformer
Controls which model attributes are included in API responses and how they are formatted. The transformer is resolved automatically from the model name (PostTransformer for Post), or set explicitly via $transformer on the filter service or controller.
use Devespresso\LaravelApiKit\Transformers\BaseTransformer; class PostTransformer extends BaseTransformer { protected $formats = [ // Always included '*' => [ 'id', 'title', 'status', '@word_count', // custom attribute (computed via transformer method) '~reading_time', // accessor attribute (Laravel model accessor, not a DB column) '!internal_notes', // hidden (excluded from output) 'author' => [ // nested relation 'id', 'name', '!password', // hidden within the relation ], ], // Merged with * on the show route 'show' => [ 'body', 'created_at', ], // Returned as-is on index — does NOT merge with * '_index' => [ 'id', 'title', ], ]; // Rename output keys protected $renames = [ '*' => ['created_at' => 'createdAt'], // global rename 'author.name' => 'authorName', // path-specific rename ]; // Format attribute values protected $formatters = [ '*' => ['status' => 'formatStatus'], // global formatter 'author.name' => ['toUpper'], // path-specific formatter ]; // Computed attributes resolved via methods (used with the '@' prefix) protected $customAttributes = [ 'word_count' => 'getWordCount', ]; // Default values when an attribute is null protected $defaults = [ '*' => ['status' => 'draft'], // global scalar default 'author.bio' => 'getBioDefault', // path-specific method default ]; // Conditionally hide attributes based on the current user/context protected $guarded = [ '*' => ['salary' => 'isNotAdmin'], // global guard 'user.secret' => 'isNotOwner', // path-specific guard ]; // Custom attribute methods (called with the model) public function getWordCount($model): int { return str_word_count($model->body ?? ''); } // Formatter methods public function formatStatus($value): string { return ucfirst($value); } // Guard methods (return true to hide, false to show) public function isNotAdmin($model): bool { return !auth()->user()?->isAdmin(); } }
Attribute Prefixes
| Prefix | Meaning |
|---|---|
!attribute |
Hidden — excluded from output. On a relation key, still eager-loaded for SELECT purposes but not returned. |
@attribute |
Custom — value resolved via the $customAttributes map instead of reading from the database. |
~attribute |
Accessor — a Laravel model accessor. Not added to the SELECT query, but read from the model and included in the output. |
All prefixes are configurable via
config/devespressoApi.phpundertransformers.prefixes. If your attribute names clash with the defaults, change them there and the entire package will use your values automatically.
Format Key Prefixes
| Format key | Behaviour |
|---|---|
* |
Wildcard — always included, merged with the matched route key |
show, index, etc. |
Merged on top of * for that controller method |
_index |
Returned standalone — does not merge with * |
API Versioning
When your API evolves across versions, the transformer's versioning system lets you describe what changes at each version — without creating separate transformer files.
Enable versioning in the config:
'versioning' => [ 'enabled' => true, 'driver' => 'route_prefix', // 'route_prefix' | 'header' 'header' => 'X-Api-Version', // used when driver = 'header' 'versions' => ['v2', 'v3'], // ordered — v3 builds on v2 ],
Define your base format and version methods on the transformer:
class PostTransformer extends BaseTransformer { // Declare the highest version this transformer explicitly supports. // Any version within this boundary that has no method will throw. // Leave null to silently skip missing version methods. protected ?string $latestVersion = 'v3'; // The starting point — used by all versions. // Use this method instead of $formats when versioning is enabled. protected function baseFormat(): array { return [ '*' => ['id', 'title', 'status'], 'show' => ['email', 'created_at'], ]; } // v2 builds on base protected function v2Format(): array { return [ 'append' => [ '*' => ['avatar'], 'show' => ['phone'], ], 'remove' => [ '*' => ['status'], // removed in v2 ], ]; } // v3 builds on v2 protected function v3Format(): array { return [ 'append' => [ '*' => ['verified_at'], ], ]; } }
Resolution chain:
| Request version | Formats applied |
|---|---|
| unversioned / none | baseFormat() only |
v2 |
baseFormat() → v2Format() |
v3 |
baseFormat() → v2Format() → v3Format() |
v445 (unknown) |
baseFormat() → v2Format() → v3Format() (falls back to latest) |
Nested relations — append and remove work at any depth, mirroring the existing format shape:
protected function v2Format(): array { return [ 'append' => [ '*' => [ 'author' => ['bio'], // adds bio inside the author relation ], ], 'remove' => [ '*' => [ 'author' => ['email'], // removes email from inside author ], ], ]; }
Standalone versions — use merge: false to replace all accumulated formats and start fresh from that version:
protected function v2Format(): array { return [ 'merge' => false, 'formats' => [ '*' => ['id', 'avatar'], // completely replaces base 'show' => ['phone'], ], ]; }
Subsequent versions still build on top of the standalone result. Note that merge: false only resets the accumulated formats — property overrides (renames, formatters, guarded, defaults, customAttributes) always accumulate cumulatively regardless.
Versioned property overrides
Version methods can also override renames, formatters, guarded, defaults, and customAttributes — the same properties you set on the transformer class. The rule is simple:
- Class properties (
$renames,$formatters, etc.) are the base — always applied regardless of version. - Version method keys are additive — merged on top of the base for that call, never touching the class properties.
class PostTransformer extends BaseTransformer { // Always-on base renames protected $renames = [ '*' => ['created_at' => 'createdAt'], ]; // Always-on base formatters protected $formatters = [ '*' => ['title' => 'ucwords'], ]; protected function baseFormat(): array { return ['*' => ['id', 'title', 'status', 'created_at']]; } protected function v2Format(): array { return [ 'append' => ['*' => ['name', 'avatar']], // Layered on top of $renames — base rename is preserved 'renames' => [ '*' => ['name' => 'fullName'], // global rename 'author.name' => 'authorName', // dot-notation path rename ], // Layered on top of $formatters 'formatters' => [ '*' => ['status' => 'toUpper'], ], // Layered on top of $guarded 'guarded' => [ '*' => ['salary' => 'isNotAdmin'], ], // Layered on top of $defaults 'defaults' => [ '*' => ['avatar' => 'https://example.com/default.png'], 'author.bio' => 'getDefaultBio', ], // Layered on top of $customAttributes 'customAttributes' => [ 'greeting' => 'getGreeting', ], ]; } protected function v3Format(): array { return [ // Adds to v2's renames — all three renames are active for v3 'renames' => [ '*' => ['status' => 'userStatus'], ], ]; } }
What the user gets per version:
| Version | Active renames |
|---|---|
| base | created_at → createdAt |
| v2 | + name → fullName, author.name → authorName |
| v3 | + status → userStatus |
The chain accumulates across versions — v3's renames are merged on top of v2's, which are merged on top of the base. Later versions override earlier values for the same key.
Base properties are never mutated — calling resolveVersionedFormats() on the same transformer instance with different versions is safe. Versioned state is isolated per call and reset at the start of each resolution.
Key validation — version methods only accept the following keys. Any typo (e.g. 'appned' instead of 'append') throws an \InvalidArgumentException immediately with a clear message listing both the bad key and the valid ones:
Valid keys: merge, formats, append, remove, renames, formatters, guarded, defaults, customAttributes
Also, merge: false requires a formats key — omitting it throws as well.
$latestVersion — opt-in strict mode:
protected ?string $latestVersion = 'v3';
When set, any version within this boundary that is missing its format method throws a RuntimeException with a descriptive message. Versions beyond $latestVersion are always skipped silently — not every transformer needs to change at every version.
Driver: route_prefix — detects the version from the start of the route URI. A route registered at v2/posts/{id} resolves to v2. A route whose URI is exactly the version string (e.g. v2 with no trailing path) also matches.
Driver: header — reads the version from the configured request header:
'driver' => 'header', 'header' => 'X-Api-Version', // GET /posts with X-Api-Version: v2
Reading the resolved version — after setData() is called on the controller, the resolved version is available on the controller via $this->version. On the transformer it is available via getResolvedVersion(). Both return null when versioning is disabled or no version was detected.
When an unknown version is requested (e.g. v445) the system falls back to the full chain and both properties reflect the last known version (v3), not the raw requested value — so callers always see the effective version that was actually applied.
$wrapper
The $wrapper property controls the key name used to wrap the transformed data in the response. If not set, it defaults to 'data':
protected $wrapper = 'post'; // produces: {"post": {...}} instead of {"data": {...}}
The wrapper can also be overridden per-call from the controller via setData($post, 'post').
Transformer-Driven Query
When auto_select and auto_eager_load are enabled, the filter service reads your transformer's $formats definition and automatically builds an optimised query — no SELECT *, no N+1.
Given this transformer:
class PostTransformer extends BaseTransformer { protected $formats = [ '*' => [ 'id', 'title', 'status', 'user_id', // foreign key — must be included so Laravel can match // the eager-loaded authors. Use '!user_id' instead if // you want it selected but hidden from the response. '@word_count', // custom attribute — excluded from SELECT, resolved via transformer method '~reading_time', // accessor attribute — excluded from SELECT, resolved via model accessor '!team_id', // hidden from output — but still SELECTed (useful for auth checks) 'author' => [ // relation — auto eager-loaded 'id', 'name', '!email', // hidden from output — but still SELECTed ], ], ]; }
Important: always include the foreign key that connects the relation (e.g.
user_idon posts) in your transformer format. Without it, the column won't be selected and the eager-loaded relation will return empty. Use the plain key to include it in the response, or prefix it with!to select it silently.
Calling Post::filter($request->validated(), $request->user()) generates exactly:
SELECT posts.id, posts.title, posts.status, posts.user_id, posts.team_id FROM posts WHERE ... -- one eager-load query, no N+1: SELECT users.id, users.name, users.email FROM users WHERE users.id IN (1, 2, 3, ...)
@word_count and ~reading_time are both excluded from SELECT — the difference is how their values are resolved: @ calls a method on the transformer, while ~ calls the model accessor directly ($model->reading_time).
And the JSON response includes only what was declared as visible — ! prefixed fields are fetched but stripped from the output:
{
"posts": [
{
"id": 1,
"title": "Hello World",
"status": "published",
"user_id": 5,
"word_count": 42,
"reading_time": 3,
"author": {
"id": 5,
"name": "Alice"
}
}
]
}
team_idand!— they never appear in the response.user_idis selected and visible since it was declared without a prefix. If you changed it to'!user_id'in the transformer, it would still be selected but would disappear from the response.
The Eloquent equivalent you would otherwise write by hand:
// @word_count and ~reading_time are omitted from SELECT — they are resolved // after the query via the transformer method and model accessor respectively. Post::select('posts.id', 'posts.title', 'posts.status', 'posts.user_id', 'posts.team_id') ->with(['author' => fn ($q) => $q->select('users.id', 'users.name', 'users.email')]) ->where('team_id', $user->team_id) ->where('published', true) ->simplePaginate($perPage);
With the package, that query is derived automatically from the transformer — you never write it, and it stays in sync with your response format as the transformer evolves.
4. BaseRepository
Provides standard CRUD with lifecycle hooks. Automatically resolves the model from the repository class name (PostRepository → Post).
use Devespresso\LaravelApiKit\Repositories\BaseRepository; class PostRepository extends BaseRepository { // Optional: override auto-resolved model protected $model = Post::class; // Hooks protected function beforeCreate(array &$attributes): void { $attributes['slug'] = Str::slug($attributes['title']); } protected function afterCreated(Model $model, array $attributes): void { event(new PostCreated($model)); } protected function beforeUpdate(?Model $model, array &$attributes): void { if (isset($attributes['title'])) { $attributes['slug'] = Str::slug($attributes['title']); } } protected function afterUpdated(Model $model, array $attributes): void { Cache::forget("post:{$model->id}"); } protected function beforeDelete(Model $model): void { // runs before delete } protected function afterDeleted(Model $model): void { // runs after delete } }
Available methods:
$repo->index($data, $user); // filtered, paginated list $repo->index($data, $user, explicitFilters: ['status', 'name']); // with explicit filter allowlist $repo->get($id); // single record $repo->create($attributes); // create with hooks $repo->update($model, $attributes); // update with hooks $repo->delete($model); // delete with hooks
To skip hooks for a single operation, chain withoutHooks() before the call. The skip list resets automatically after each operation.
// Skip all hooks $repo->withoutHooks()->delete($model); // Skip specific hooks only $repo->withoutHooks('afterCreated')->create($attributes); $repo->withoutHooks('beforeUpdate', 'afterUpdated')->update($model, $attributes);
5. ApiController
Base controller for JSON API responses. Automatically resolves a transformer and repository from the controller class name.
use Devespresso\LaravelApiKit\Controllers\ApiController; class PostController extends ApiController { public function index(PostRequest $request): JsonResponse { return $this->setData( $this->repository->index($request->validated(), $request->user()) )->respond(); } public function store(PostRequest $request): JsonResponse { return $this->setData( $this->repository->create($request->validated()) )->respondCreated(); } public function destroy(Post $post): JsonResponse { $this->repository->delete($post); return $this->respondNoContent(); } }
Response shortcuts
respondCreated() returns a 201 Created response. respondNoContent() returns a 204 No Content response:
return $this->setData($post)->respondCreated(); return $this->respondNoContent();
setRawData() — bypass the transformer
Use setRawData() to add data to the response without going through the transformer. Defaults to the 'data' key:
// Uses the default 'data' wrapper return $this->setRawData(['total' => 100, 'active' => 42])->respond(); // Custom key return $this->setRawData(['total' => 100], 'stats')->respond();
This is especially useful when autoResolveTransformer is disabled, or when the data doesn't come from a model.
appendTo() — accumulate multiple values under a key
Use appendTo() to push values onto a response key rather than replacing it. Each call appends to the array. Defaults to the 'data' key:
$this->appendTo(['id' => 1, 'name' => 'Alice']); $this->appendTo(['id' => 2, 'name' => 'Bob']); return $this->respond(); // "data": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
Use a custom key to keep different datasets separate:
$this->appendTo($post, 'posts'); $this->appendTo($stats, 'meta'); return $this->respond();
setMeta() and addMeta() — response metadata
Attach metadata (permissions, roles, feature flags, etc.) to the response via the meta key:
// Bulk set $this->setMeta(['permissions' => ['edit', 'delete'], 'roles' => ['admin']]); // Incremental — chainable $this->addMeta('permissions', ['edit', 'delete']) ->addMeta('roles', ['admin']);
setMeta() replaces the entire meta array. addMeta() adds a single key-value pair. The meta key is only included in the response when non-empty.
respond() — merging extra data
You can pass an array to respond() to merge additional data into the response, or override existing keys entirely:
// Merge extra keys into the response return $this->setData($post)->respond(['extra' => 'value']); // Override a key set by setData() return $this->setData($post)->respond(['post' => $customPayload], override: true);
setData() optional parameters
setData() accepts two optional arguments that give you finer control over the response shape:
-
$wrapper— overrides the key name used to wrap the data in the response. By default the transformer's own$wrappervalue is used. Passing a string replaces it for that call:return $this->setData($post, 'post')->respond(); // produces: {"post": {...}} instead of the transformer default
-
$format— selects a specific format key from the transformer's$formatsarray instead of auto-detecting from the current route action:return $this->setData($post, format: 'show')->respond(); // uses the 'show' format from PostTransformer
Overriding the transformer at runtime
Use setTransformer() to swap out the auto-resolved transformer for a specific call. Useful when one controller needs to serve multiple models or formats:
return $this->setTransformer(SummaryTransformer::class) ->setData($post) ->respond();
setCode() and error responses
setCode() automatically sets status to "error" for any code >= 400. An optional second argument sets a custom message:
return $this->setCode(404, 'Post not found')->respond(); // {"code": 404, "status": "error", "message": "Post not found"}
Disabling auto-resolution
Both $autoResolveRepository and $autoResolveTransformer can be set to false on the subclass to disable auto-resolution when you want full manual control:
class PostController extends ApiController { protected bool $autoResolveRepository = false; protected bool $autoResolveTransformer = false; }
Default response format:
{
"code": 200,
"status": "success",
"message": "OK",
"meta": { ... },
"data": { ... },
"pagination": { ... }
}
metais only present when metadata has been set viasetMeta()oraddMeta().
6. BaseRequest
Auto-dispatches validation rules and authorization per controller method. Includes built-in rules for pagination and sorting on all list endpoints.
use Devespresso\LaravelApiKit\Requests\BaseRequest; class PostRequest extends BaseRequest { protected function actionsRules(): array { return [ 'store' => [ 'title' => ['required', 'string', 'max:255'], 'body' => ['required', 'string'], ], 'update' => fn () => [ 'title' => ['sometimes', 'string', 'max:255'], 'body' => ['sometimes', 'string'], ], ]; } // Optional per-action authorization protected function storeAuth(): bool { return $this->user()->can('create', Post::class); } }
Built-in rules available on all requests (from indexRules()):
| Key | Rule |
|---|---|
sort |
string |
per_page |
integer, min:1, max:100 |
with_pages |
boolean |
pagination_type |
in:paginate,none,simple |
7. BaseAuthorisationService
Property-based authorisation checks, usable standalone or from filter services.
use Devespresso\LaravelApiKit\Services\Authorisation\BaseAuthorisationService; class PostAuthorisationService extends BaseAuthorisationService { protected $mainProperty = 'post'; } // In a controller or service: $auth = (new PostAuthorisationService()) ->setUser($user) ->setProperties(['post' => $post]) ->doesItBelongToUser() // asserts post->user_id === $user->id ->requireUser() // asserts user is authenticated ->passwordVerification($password);
Use skipExceptions() to collect errors instead of throwing:
$auth = (new PostAuthorisationService()) ->skipExceptions() ->setUser($user) ->setProperties(['post' => $post]) ->doesItBelongToUser(); if (!$auth->isValid()) { return response()->json(['errors' => $auth->getErrors()], 403); }
Running Tests
composer test
License
MIT