A complete data filtering, transformation, and API response system for Laravel. Drop it into any application to get automatic query filtering, model transformation, pagination, sorting, authorisation, and CRUD repositories — all driven by simple class conventions.
composer require devespresso/laravel-api-kitlaravel-api-kit provides seven coordinated components that replace the boilerplate in every Laravel API: query filtering, response transformation, CRUD repositories, a base controller, form request dispatch, and role-based authorisation — all wired together automatically by class naming conventions.
Generate all 7 classes for a resource in a single Artisan command.
Transformer drives the query — no SELECT *, no N+1, no manual ->with().
Request keys can only call methods the current role is allowed to trigger.
Evolve response shapes across versions without duplicating transformer files.
composer require devespresso/laravel-api-kit
Publish the config file:
php artisan vendor:publish --provider="Devespresso\LaravelApiKit\LaravelApiKitServiceProvider"
This creates config/devespressoApi.php.
Requires PHP 8.1+ and Laravel 10, 11, 12, or 13.
Generate a complete API resource in one command:
php artisan devespresso:api-kit:scaffold Post
Creates all 7 classes — paths follow your paths config:
| 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 |
# Only 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
Valid names for --only / --except: model, repository, controller, transformer, request, authorisation, filter-service.
return [ // false = simplePaginate() | true = paginate() (includes total count) 'pagination' => ['with_pages' => false], // Auto SELECT only transformer-required columns — prevents SELECT * 'auto_select' => true, // Auto eager-load nested relations defined in the transformer — prevents N+1 'auto_eager_load' => true, // When true, only explicitly whitelisted request keys drive filter methods 'enable_explicit_filtering' => false, // Invokable class returning the current user's role — must be cacheable 'role_resolver' => App\Support\RoleResolver::class, 'roles' => ['moderator', 'editor', 'admin'], // ordered lowest → highest 'numeric_roles' => false, // Namespaces — scaffold places files here, auto-resolution reads from here '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\\', ], // Single-char prefixes in transformer $formats — change if they clash with your column names 'transformers' => [ 'prefixes' => [ 'hidden_attributes' => '!', // SELECTed but excluded from JSON output 'custom_attributes' => '@', // computed via transformer method 'accessor_attributes' => '~', // Laravel model accessor 'unmerged_format' => '_', // format key not merged with '*' ], ], ];
Add to any Eloquent model to unlock the filter() static method.
use Devespresso\LaravelApiKit\Traits\EnableDatabaseFiltering; class Post extends Model { use EnableDatabaseFiltering; protected $defaultFilterService = PostFilterService::class; // optional // Columns searched when 'search' key is present in the request protected $searchableColumns = ['title', 'body']; }
// Basic — validated request data drives all filters $posts = Post::filter( data: $request->validated(), user: $request->user(), ); // Pre-scoped query — base constraints enforced before filters run $posts = Post::filter( data: $request->validated(), user: $request->user(), query: Post::where('team_id', $team->id), ); // Extra context passed to the filter service $posts = Post::filter( data: $request->validated(), user: $request->user(), extras: ['team' => $team], ); // Explicit filter allowlist — only listed keys trigger filter methods $posts = Post::filter( data: $request->validated(), user: $request->user(), explicitFilters: ['status', 'author_id'], );
| 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
Each key in the validated request is camelCased and dispatched to the matching public method. ?author_id=5 calls authorId(5).
use Devespresso\LaravelApiKit\Services\Filters\BaseFilterService; class PostFilterService extends BaseFilterService { // Columns users can sort by protected $sortColumns = ['created_at', 'updated_at', 'id', 'title']; // Friendly alias => real column protected $customSortColumns = ['date' => 'created_at']; // Complex sort alias => method returning raw SQL expression protected $rawSort = ['status_order' => 'sortByStatus']; // Default sort when no 'sort' key is in the request protected $defaultSortingColumn = ['created_at,desc']; // Block these public methods from request dispatch protected $guardedMethods = ['sensitiveMethod']; // Methods restricted to specific roles (hierarchical) protected $roleMethods = [ 'moderator' => ['includeArchived'], 'editor' => ['includeUnpublished'], 'admin' => ['includeTrashed', 'byAnyTeam'], ]; // Always applied regardless of request data protected $autoApply = ['onlyPublished' => true]; // Baseline scope — runs before any filter methods protected function setConditions(): void { $this->query->where('team_id', $this->user->team_id); } // Triggered by ?status=published public function status(string $value): void { $this->query->where('status', $value); } // Triggered by ?author_id=5 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); } // Admin-only: include soft-deleted records public function includeTrashed(bool $value): void { if ($value) { $this->query->withTrashed(); } } // Complex raw SQL sort — triggered by ?sort=status_order,asc protected function sortByStatus(): string { return "FIELD(status, 'active', 'pending', 'closed')"; } // Internal helper — protected, so it can never be triggered from request data protected function applyTeamScope(): void { $this->query->where('team_id', $this->user->team_id); } }
| Method | Description |
|---|---|
| $this->getDataValue('key', $default) | Get a value from request data |
| $this->dataHasValue('key', 'value') | Check if a key equals a specific value |
| $this->dataHasKeys(['key1', 'key2']) | Check all keys are present |
| $this->getExtraProperty('key') | Read from the $extras context array |
| $this->with(['relation']) | Eager load additional relations |
| $this->withCount(['relation']) | Load relation counts |
| $this->disableConditions() | Skip setConditions() for this call |
| $this->setSelect(['col']) | Override auto-selected columns |
| $this->getEffectiveRoles() | Return the current user's expanded role list |
GET /posts?sort=created_at,desc GET /posts?sort[]=title,asc&sort[]=created_at,desc GET /posts?sort=status_order,asc # triggers $rawSort method
class RoleResolver { public function __invoke(?Authenticatable $user): mixed { return $user?->role; // e.g. 'admin', 'editor', or null } }
Must be an invokable class — closures are not supported because the config must be cacheable (php artisan config:cache).
Controls which model attributes appear in API responses, how they are formatted, and drives the auto SELECT / eager-load query optimisation.
| Prefix | Meaning |
|---|---|
| ! (bang) | SELECTed from DB but excluded from the JSON output |
| @ (at) | Computed — value resolved via the $customAttributes method map |
| ~ (tilde) | Laravel model accessor — not added to SELECT, read via $model->attribute |
| Key | Behaviour |
|---|---|
| * | Wildcard — always included, merged with the matched route key |
| show / index | Merged on top of * for that controller action |
| _index | Returned standalone — does NOT merge with * |
use Devespresso\LaravelApiKit\Transformers\BaseTransformer; class PostTransformer extends BaseTransformer { // Wrap response under this key instead of the default 'data' protected $wrapper = 'post'; protected $formats = [ // Always included in every response '*' => [ 'id', 'title', 'status', '!user_id', // SELECTed (for eager-load join), hidden from output '@word_count', // calls $this->getWordCount($model) '~reading_time', // calls $model->getReadingTimeAttribute() 'author' => [ // nested relation — auto eager-loaded 'id', 'name', '!email', // SELECTed but hidden ], ], // Merged with * on the 'show' action 'show' => ['body', 'created_at'], // Standalone — does NOT merge with *, returned as-is on 'index' '_index' => ['id', 'title'], ]; // Rename output keys protected $renames = [ '*' => ['created_at' => 'createdAt'], 'author.name' => 'authorName', ]; // Format attribute values via method names protected $formatters = [ '*' => ['status' => 'formatStatus'], ]; // Default values when attribute is null protected $defaults = [ '*' => ['status' => 'draft'], ]; // Conditionally hide attributes — method returns true to hide protected $guarded = [ '*' => ['salary' => 'isNotAdmin'], ]; // Custom attribute methods (used with '@' prefix) protected $customAttributes = [ 'word_count' => 'getWordCount', ]; public function getWordCount($model): int { return str_word_count($model->body ?? ''); } public function formatStatus($value): string { return ucfirst($value); } public function isNotAdmin($model): bool { return !auth()->user()?->isAdmin(); } }
With auto_select and auto_eager_load enabled, the transformer drives the query automatically. Given the format above, calling Post::filter(...) generates:
SELECT posts.id, posts.title, posts.status, posts.user_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, ...)
Always include the foreign key (user_id) in your format. Without it, the column won't be selected and the eager-loaded relation returns empty. Use !user_id to select it silently without including it in the response.
Enable in config, then define version methods on the transformer. Each version builds on the previous — no separate transformer files needed.
'versioning' => [ 'enabled' => true, 'driver' => 'route_prefix', // or 'header' 'header' => 'X-Api-Version', // used when driver = 'header' 'versions'=> ['v2', 'v3'], // ordered — v3 builds on v2 ],
class PostTransformer extends BaseTransformer { // Throw if a version within this boundary is missing its method protected ?string $latestVersion = 'v3'; protected function baseFormat(): array { return [ '*' => ['id', 'title', 'status'], 'show' => ['created_at'], ]; } protected function v2Format(): array { return [ 'append' => ['*' => ['avatar']], 'remove' => ['*' => ['status']], 'renames'=> ['*' => ['title' => 'heading']], ]; } protected function v3Format(): array { return [ 'append' => ['*' => ['verified_at']], ]; } }
| Request version | Formats applied |
|---|---|
| none / unversioned | baseFormat() only |
| v2 | baseFormat() → v2Format() |
| v3 | baseFormat() → v2Format() → v3Format() |
| v99 (unknown) | Falls back to latest known chain (v3) |
Standard CRUD with lifecycle hooks. Auto-resolves the model from the class name (PostRepository → Post).
use Devespresso\LaravelApiKit\Repositories\BaseRepository; class PostRepository extends BaseRepository { protected $model = Post::class; // optional — auto-resolved from class name 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 afterDeleted(Model $model): void { } }
$repo->index($data, $user); $repo->index($data, $user, explicitFilters: ['status', 'name']); $repo->get($id); $repo->create($attributes); $repo->update($model, $attributes); $repo->delete($model); // Skip all hooks $repo->withoutHooks()->delete($model); // Skip specific hooks only $repo->withoutHooks('afterCreated')->create($attributes); $repo->withoutHooks('beforeUpdate', 'afterUpdated')->update($model, $attributes);
Base controller for JSON responses. Auto-resolves a transformer and repository from the class name. Provides a fluent response-building API.
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(); // 201 Created } public function show(PostRequest $request, Post $post): JsonResponse { return $this->setData($post)->respond(); } public function update(PostRequest $request, Post $post): JsonResponse { return $this->setData( $this->repository->update($post, $request->validated()) )->respond(); } public function destroy(Post $post): JsonResponse { $this->repository->delete($post); return $this->respondNoContent(); // 204 No Content } }
// Merge extra keys into the response $this->setData($post)->respond(['extra' => 'value']); // Override wrapper key and format $this->setData($post, 'post', format: 'show')->respond(); // Bypass the transformer entirely $this->setRawData(['total' => 100, 'active' => 42], 'stats')->respond(); // Accumulate items under one key $this->appendTo($post1, 'posts'); $this->appendTo($post2, 'posts')->respond(); // Attach metadata $this->addMeta('permissions', ['edit', 'delete']) ->addMeta('roles', ['admin']) ->setData($post)->respond(); // Error response $this->setCode(404, 'Post not found')->respond(); // Swap transformer at runtime $this->setTransformer(SummaryTransformer::class)->setData($post)->respond();
{ "code": 200, "status": "success", "message": "OK", "meta": { ... }, // only present when set via setMeta() / addMeta() "data": { ... }, "pagination": { ... } }
Auto-dispatches validation rules and authorization per controller action. Includes built-in pagination and sort validation 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 on all list endpoints (indexRules()):
| Key | Rule |
|---|---|
| sort | string |
| per_page | integer, min:1, max:100 |
| with_pages | boolean |
| pagination_type | in:paginate,none,simple |