devespresso/laravel-api-kit

A Laravel API kit providing filtering, transformation, repositories, request validation, and authorisation.

Maintainers

Package info

github.com/devespressostudio/laravel-api-kit

Type:project

pkg:composer/devespresso/laravel-api-kit

Statistics

Installs: 43

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.2.1 2026-03-21 15:59 UTC

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_resolver must 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:

  1. Config flag — enable it globally in config/devespressoApi.php:
'enable_explicit_filtering' => true,
  1. 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']
);

$autoApply methods 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 $rawSort are 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.php under transformers.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_id on 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_id and email were selected but hidden via ! — they never appear in the response. user_id is 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 (PostRepositoryPost).

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 $wrapper value 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 $formats array 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": { ... }
}

meta is only present when metadata has been set via setMeta() or addMeta().

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