PHPackages                             devespresso/laravel-api-kit - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. devespresso/laravel-api-kit

ActiveProject[API Development](/categories/api)

devespresso/laravel-api-kit
===========================

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

1.2.4(1mo ago)052↓77.8%MITPHPPHP ^8.1

Since Feb 10Pushed 1mo ago1 watchersCompare

[ Source](https://github.com/devespressostudio/laravel-api-kit)[ Packagist](https://packagist.org/packages/devespresso/laravel-api-kit)[ RSS](/packages/devespresso-laravel-api-kit/feed)WikiDiscussions master Synced 3w ago

READMEChangelog (10)Dependencies (12)Versions (26)Used By (0)

Laravel API Kit
===============

[](#laravel-api-kit)

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

[](#requirements)

- PHP 8.1+
- Laravel 10, 11, 12, or 13

Installation
------------

[](#installation)

```
composer require devespresso/laravel-api-kit
```

Publish the config file:

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

Scaffolding
-----------

[](#scaffolding)

Generate a full API resource with a single command:

```
php artisan devespresso:api-kit:scaffold Post
```

This creates all 7 components at once:

ComponentGenerated ClassModel`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

[](#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
-------------

[](#configuration)

Publish the config file to `config/devespressoApi.php`:

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

---

### `pagination.with_pages`

[](#paginationwith_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`

[](#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`

[](#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`

[](#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`

[](#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](#explicit-filtering) for usage.

---

### `roles`, `numeric_roles`, `role_resolver`

[](#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`](#4-rolemethods--restrict-methods-to-specific-roles) for usage.

---

### `transformers.prefixes`

[](#transformersprefixes)

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 '*'
    ],
],
```

KeyDefaultEffect`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
---------------

[](#core-components)

### 1. `EnableDatabaseFiltering` Trait

[](#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`

[](#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

[](#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

[](#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

[](#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

[](#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,
```

2. **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

[](#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`cursor``cursorPaginate()` — cursor-based, no total count`none``get()` — returns all results```
GET /posts?pagination_type=none&per_page=50
GET /posts?pagination_type=cursor&per_page=25&cursor=eyJpZCI6MTB9...

```

**Cursor pagination and sorting**

Cursor pagination works by encoding the last-seen row position into an opaque token. Laravel reads the `cursor` query parameter automatically — you do not need to handle it in your filter service.

Because the cursor points to a specific row, **the sort order must be stable and unique across the full result set**. If two rows can produce the same sort key, Laravel cannot determine a reliable position and will skip or repeat rows between pages.

Rules to follow:

- Always include a unique column (typically `id`) as the final sort key. A sort like `created_at,desc` is not stable on its own — two posts can share the same `created_at` timestamp. Add `id` as a tiebreaker: `sort[]=created_at,desc&sort[]=id,desc`.
- Never use `rawSort` with cursor pagination unless the raw expression is fully deterministic and unique per row.
- Avoid sorts on nullable columns without a fallback — `NULL` values make the cursor position ambiguous.

The `defaultSortingColumn` on `BaseFilterService` is `['id,desc']`, which is safe for cursor pagination out of the box. If you change it in your subclass, make sure to keep `id` as the final tiebreaker:

```
protected $defaultSortingColumn = ['created_at,desc', 'id,desc'];
```

Laravel will throw a `RuntimeException` if it cannot encode a valid cursor from the current sort — which is a clear signal that the ordering is not stable enough.

#### Sorting

[](#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`

[](#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

[](#attribute-prefixes)

PrefixMeaning`!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-prefixes)

Format keyBehaviour`*`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

[](#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 versionFormats appliedunversioned / 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

[](#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:**

VersionActive renamesbase`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`

[](#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

[](#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`

[](#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`

[](#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

[](#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

[](#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

[](#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

[](#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

[](#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-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

[](#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-and-error-responses)

`setCode()` automatically sets `status` to `"error"` for any code &gt;= 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

[](#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`

[](#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()`):

KeyRule`sort`string`per_page`integer, min:1, max:100`with_pages`boolean`pagination_type`in:paginate,none,simple,cursor---

### 7. `BaseAuthorisationService`

[](#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
-------------

[](#running-tests)

```
composer test
```

---

License
-------

[](#license)

MIT

###  Health Score

47

—

FairBetter than 93% of packages

Maintenance90

Actively maintained with recent releases

Popularity10

Limited adoption so far

Community9

Small or concentrated contributor base

Maturity68

Established project with proven stability

 Bus Factor1

Top contributor holds 94.7% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~64 days

Recently: every ~12 days

Total

25

Last Release

49d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/86877979?v=4)[devespresso](/maintainers/devespresso)[@devEspresso](https://github.com/devEspresso)

---

Top Contributors

[![devespressohq](https://avatars.githubusercontent.com/u/268158236?v=4)](https://github.com/devespressohq "devespressohq (36 commits)")[![devespressostudio](https://avatars.githubusercontent.com/u/20487821?v=4)](https://github.com/devespressostudio "devespressostudio (2 commits)")

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/devespresso-laravel-api-kit/health.svg)

```
[![Health](https://phpackages.com/badges/devespresso-laravel-api-kit/health.svg)](https://phpackages.com/packages/devespresso-laravel-api-kit)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.1M337](/packages/psalm-plugin-laravel)[spatie/laravel-query-builder

Easily build Eloquent queries from API requests

4.5k29.4M280](/packages/spatie-laravel-query-builder)[essa/api-tool-kit

set of tools to build an api with laravel

53386.5k](/packages/essa-api-tool-kit)[simplestats-io/laravel-client

Analytics for Laravel. Track visitors, registrations, and payments. Discover which channels actually drive revenue, not just traffic. Server-side, GDPR compliant, ad-blocker proof.

5019.3k](/packages/simplestats-io-laravel-client)[pressbooks/pressbooks

Pressbooks is an open source book publishing tool built on a WordPress multisite platform. Pressbooks outputs books in multiple formats, including PDF, EPUB, web, and a variety of XML flavours, using a theming/templating system, driven by CSS.

45344.0k1](/packages/pressbooks-pressbooks)[api-platform/laravel

API Platform support for Laravel

59156.3k11](/packages/api-platform-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
