Portfolio/Packages/laravel-api-kit
StablePHP 8.1+Laravel 10–13MIT

laravel-api-kit

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.

GitHub composer require devespresso/laravel-api-kit

Introduction

laravel-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.

One-command scaffold

Generate all 7 classes for a resource in a single Artisan command.

Auto SELECT & eager-load

Transformer drives the query — no SELECT *, no N+1, no manual ->with().

Role-based filter security

Request keys can only call methods the current role is allowed to trigger.

API versioning built-in

Evolve response shapes across versions without duplicating transformer files.

Installation

bash
composer require devespresso/laravel-api-kit

Publish the config file:

bash
php artisan vendor:publish --provider="Devespresso\LaravelApiKit\LaravelApiKitServiceProvider"

This creates config/devespressoApi.php.

Requires PHP 8.1+ and Laravel 10, 11, 12, or 13.

Scaffolding

Generate a complete API resource in one command:

bash
php artisan devespresso:api-kit:scaffold Post

Creates all 7 classes — paths follow your paths config:

ComponentGenerated class
ModelApp\Models\Post
RepositoryApp\Repositories\PostRepository
ControllerApp\Http\Controllers\PostController
TransformerApp\Transformers\PostTransformer
RequestApp\Http\Requests\PostRequest
AuthorisationApp\Services\Authorisation\PostAuthorisationService
Filter ServiceApp\Services\Filters\PostFilterService

Options

bash
# 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.

Configuration

php · config/devespressoApi.php
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 '*'
        ],
    ],

];

EnableDatabaseFiltering Trait

Add to any Eloquent model to unlock the filter() static method.

php · app/Models/Post.php
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'];
}

Calling filter() from a controller

php
// 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 via request

pagination_typeResult
(not set)simplePaginate() or paginate() based on config
simplesimplePaginate() — no total count query
paginatepaginate() — includes total count
noneget() — returns all results
http
GET /posts?pagination_type=none&per_page=50

BaseFilterService

Each key in the validated request is camelCased and dispatched to the matching public method. ?author_id=5 calls authorId(5).

php · app/Services/Filters/PostFilterService.php
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);
    }
}

Available helpers inside filter methods

MethodDescription
$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

Sorting

http
GET /posts?sort=created_at,desc
GET /posts?sort[]=title,asc&sort[]=created_at,desc
GET /posts?sort=status_order,asc  # triggers $rawSort method

Role resolver

php · app/Support/RoleResolver.php
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).

BaseTransformer

Controls which model attributes appear in API responses, how they are formatted, and drives the auto SELECT / eager-load query optimisation.

Attribute prefixes

PrefixMeaning
! (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

Format keys

KeyBehaviour
*Wildcard — always included, merged with the matched route key
show / indexMerged on top of * for that controller action
_indexReturned standalone — does NOT merge with *

Full transformer example

php · app/Transformers/PostTransformer.php
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();
    }
}

Auto SELECT & eager-load

With auto_select and auto_eager_load enabled, the transformer drives the query automatically. Given the format above, calling Post::filter(...) generates:

sql — auto-generated
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.

API versioning

Enable in config, then define version methods on the transformer. Each version builds on the previous — no separate transformer files needed.

php · config/devespressoApi.php
'versioning' => [
    'enabled' => true,
    'driver'  => 'route_prefix',  // or 'header'
    'header'  => 'X-Api-Version',  // used when driver = 'header'
    'versions'=> ['v2', 'v3'],        // ordered — v3 builds on v2
],
php · PostTransformer.php
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 versionFormats applied
none / unversionedbaseFormat() only
v2baseFormat() → v2Format()
v3baseFormat() → v2Format() → v3Format()
v99 (unknown)Falls back to latest known chain (v3)

BaseRepository

Standard CRUD with lifecycle hooks. Auto-resolves the model from the class name (PostRepositoryPost).

php · app/Repositories/PostRepository.php
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 { }
}

Available methods

php
$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);

ApiController

Base controller for JSON responses. Auto-resolves a transformer and repository from the class name. Provides a fluent response-building API.

php · app/Http/Controllers/PostController.php
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
    }
}

Response helpers

php
// 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();

Default response shape

json
{
    "code":       200,
    "status":     "success",
    "message":    "OK",
    "meta":       { ... },  // only present when set via setMeta() / addMeta()
    "data":       { ... },
    "pagination": { ... }
}

BaseRequest

Auto-dispatches validation rules and authorization per controller action. Includes built-in pagination and sort validation on all list endpoints.

php · app/Http/Requests/PostRequest.php
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()):

KeyRule
sortstring
per_pageinteger, min:1, max:100
with_pagesboolean
pagination_typein:paginate,none,simple

BaseAuthorisationService

Property-based authorisation checks. Usable standalone or wired into a filter service or controller.

php · app/Services/Authorisation/PostAuthorisationService.php
use Devespresso\LaravelApiKit\Services\Authorisation\BaseAuthorisationService;

class PostAuthorisationService extends BaseAuthorisationService
{
    protected $mainProperty = 'post';
}

// Usage in a controller or service
(new PostAuthorisationService())
    ->setUser($user)
    ->setProperties(['post' => $post])
    ->doesItBelongToUser()    // asserts post->user_id === $user->id
    ->requireUser()           // asserts user is authenticated
    ->passwordVerification($password);

Collecting errors instead of throwing

php
$auth = (new PostAuthorisationService())
    ->skipExceptions()
    ->setUser($user)
    ->setProperties(['post' => $post])
    ->doesItBelongToUser();

if (!$auth->isValid()) {
    return response()->json(['errors' => $auth->getErrors()], 403);
}

Questions or contributions?

Open an issue or pull request on GitHub.

View on GitHub