escuelait / laravel-api-kit
Utilities to create consistent APIs in Laravel.
Requires
- php: ^8.3
- illuminate/http: ^12.69|^13.30
- illuminate/support: ^12.69|^13.30
- negartarh/apiwrapper: ^0.9.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.93
- orchestra/testbench: ^10.9|^11.0
- phpunit/phpunit: ^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Utils to create consisten APIs in Laravel.
Installation
Install the package via Composer:
composer require escuelait/laravel-api-kit
That's it! The package will automatically register its service provider.
Laravel compatibility: Laravel 12 or 13, with PHP 8.3+. Older laravel versions must use Api Kit 1.x
Built-in Utilities in this Package
This package offers two fundamental utilities for developing APIs that usually require significant work and are quite repetitive across different API resources.
- Searches and listings on resources with or without pagination
- Execution of actions on batches of elements
The package provides mechanisms that significantly simplify these two operations, allowing fine-grained customization of their behavior, which eliminates much of the tedious development work for resources.
Consistent API Responses
Using the mechanisms proposed by this API utilities kit, you will ensure your resources work consistently, allowing your clients to work more predictably.
One of the keys to achieving this is the use of a library that provides a common interface for generating JSON responses: Laravel API Response Wrapper. Thanks to it, a homogeneous API response schema is always used.
We encourage you to use it as well in the development of other API operations where this kit does not provide ready-made solutions.
Frontend Components
This library can be perfectly combined with the catalog of CRUD Components for Dile Components frontend.
Thanks to Dile Components' CRUD frontend components, you can easily build user interfaces to provide listings, filtering, sorting, batch action processing, as well as other operations like insertions, edits, and deletions.
If you already use Dile Components' CRUD components, you'll see that applying the Laravel solutions kit allows you to build the backend much faster.
Searches in Resource Index
Laravel API Kit provides convenient search and filtering features that can be easily implemented in API resources or in any situation where you need to return collections of elements in JSON format.
To implement this utility, two components are required:
- The
ResourceListabletrait in a controller, which provides alist()method for generating listings. - A listing service, based on
ListService, for implementing searches on models.
The ResourceListable Trait
To achieve listing functionality, we simply need to implement the ResourceListable trait in a controller.
namespace App\Http\Controllers; use EscuelaIT\APIKit\ResourceListable; class ListUsersController extends Controller { use ResourceListable; public function index() { return $this->list($userListService); } }
Thanks to implementing the ResourceListable trait in the controller, several utilities are obtained, mainly:
list()method that returns model data in JSON, performing filtering and sorting of elements, as well as optional pagination.allIds()method that allows obtaining the complete list of model identifiers given a query, once filters are applied. It's useful when you want to know all the elements that are part of a result set, regardless of pagination, to request batch actions on them.findIncluding()method that returns JSON response with a single idenfified item or a not found response if the item is not found, including optional relationships.
All this methods require receiving a service that allows detailed configuration of query aspects, such as the model to operate on or the types of filtering, among other things.
Below is an example of a controller that provides the functionalities offered by the trait:
namespace App\Http\Controllers\User; use Illuminate\Http\Request; use App\Http\Controllers\Controller; use EscuelaIT\APIKit\ResourceListable; use App\Services\User\UserListService; class ListUsersController extends Controller { use ResourceListable; public function index() { $service = new UserListService(); return $this->list($service); } public function ids() { $service = new UserListService(); return $this->allIds($service); } }
The listing customization service is what allows the ResourceListable trait to work generically, being able to operate on any entity you want to incorporate as an API resource.
ListService
ListService is the base class for services used to configure how listings behave for each resource. To define the behavior of a resource’s listing, you must pass to the list() method a ListService, usually extending the ListService class provided by this package.
Within ListService, you can configure various search and filtering behaviors to be applied in your resource controller. However, for a basic implementation, you only need to specify the model that the listing should use.
To do this, define a $listModel property and assign it the model class you want to use for listings.
namespace App\Http\Controllers\User; use App\Models\User; use EscuelaIT\APIKit\ListService; class UserListService extends ListService { protected string $listModel = User::class; }
To make your controller more testable, you can delegate the service instantiation to Laravel’s service container by injecting the instance through the controller’s constructor.
class ListUsersController extends Controller { use ResourceListable; public function __construct( private UserListService $userListService ) {} public function index() { return $this->list($this->userListService); } }
JSON Response in Listings
With the default configuration, calling the controller’s method will return a JSON response like the following:
{
"status": 200,
"message": "3 items found",
"data": {
"countItems": 3,
"lastPage": 1,
"result": {
"current_page": 1,
"current_page_url": "http:\/\/localhost\/api\/users?page=1",
"data": [
{
"id": 1,
"name": "Dr. Richie Considine MD",
"email": "lizeth21@example.com"
},
{
"id": 2,
"name": "Prof. Erling Harris",
"email": "nikita.skiles@example.com"
},
{
"id": 3,
"name": "Sabrina Lubowitz",
"email": "tanya54@example.com"
}
],
"first_page_url": "http:\/\/localhost\/api\/users?page=1",
"from": 1,
"next_page_url": null,
"path": "http:\/\/localhost\/api\/users",
"per_page": 10,
"prev_page_url": null,
"to": 3
}
},
"errors": [],
"execution": "64ms",
"version": "1"
}
countItems is the total number of items, and lastPage is the number of the last page, which is 1 when there are no items.
Transforming Items with a Laravel Resource
By default, items are returned with all the visible attributes of the model. To control the output, assign a Laravel JsonResource class to the $listResource property of your ListService. For example, this resource hides the id and returns dates in Spanish and English:
namespace App\Http\Resources; use Illuminate\Http\Resources\Json\JsonResource; class UserResource extends JsonResource { public function toArray($request): array { return [ 'uuid' => $this->uuid, 'name' => $this->name, 'created_at_es' => $this->created_at->locale('es')->isoFormat('LL'), 'created_at_en' => $this->created_at->locale('en')->isoFormat('LL'), ]; } }
class UserListService extends ListService { protected string $listModel = User::class; protected ?string $listResource = UserResource::class; }
You can also configure it using the setListResource() method:
$service->setListResource(UserResource::class);
The resource is applied to each item returned by the list() and findIncluding() methods of the ResourceListable trait. Only the content of each item changes: countItems, result and the pagination keys stay the same.
{
"status": 200,
"message": "1 items found",
"data": {
"countItems": 1,
"lastPage": 1,
"result": {
"current_page": 1,
"current_page_url": "http:\/\/localhost\/api\/users?page=1",
"data": [
{
"uuid": "9d1f6a52-3c1e-4c7a-8a55-0f2b7e3d1a10",
"name": "Sabrina Lubowitz",
"created_at_es": "15 de enero de 2026",
"created_at_en": "January 15, 2026"
}
],
"first_page_url": "http:\/\/localhost\/api\/users?page=1",
"from": 1,
"next_page_url": null,
"path": "http:\/\/localhost\/api\/users",
"per_page": 10,
"prev_page_url": null,
"to": 1
}
},
"errors": [],
"execution": "64ms",
"version": "1"
}
The class must extend JsonResource. Any other class, including a ResourceCollection, throws an InvalidResourceClassException.
Keep these points in mind when using a resource:
- Hiding the
id: removing it from the resource does not hide it everywhere.allIds()does not use the resource and returns the values of$identifierField, so set that property to another field, such asuuid.- Clients can still sort by
idunless$availableSortFieldsexcludes it, and filter byidunless$availableFilterColumnsexcludes it.
include: requested relationships only appear in the output if the resource returns them. UsewhenLoaded(), ideally with a resource for the relationship. If you useparent::toArray($request), loaded relationships are serialized in full, including their ids and foreign keys:'comments' => CommentResource::collection($this->whenLoaded('comments')),
- Performance: accessing a relationship that is not loaded runs one extra query per item. Load it with
with()increateQuery(), or usewhenLoaded().
ListService Configuration via Properties
Inside a resource’s ListService, several properties can be configured to customize how listings behave:
-
$listModelDefines the model class used for the listing. -
$listResourceDefines the LaravelJsonResourceclass applied to each returned item. The default value isnull, meaning the models are returned as they are. See Transforming Items with a Laravel Resource. -
$identifierFieldThe database table field to identify resource items, default is 'id'. You can also configure it using thesetIdentifierField()method. -
$paginatedDetermines whether the resource results should be paginated. The default value istrue, meaning pagination is enabled. -
$maxPerPageSets the maximum page size allowed for paginated results. The default value isnull, meaning no limit is enforced. When set to a positive integer, anyper_pagerequest exceeding this value will be automatically capped to the configured maximum. This is useful for preventing performance issues from excessively large page requests, e.g.:protected ?int $maxPerPage = 100;
You can also configure this using the
setMaxPerPage()method:$service->setMaxPerPage(50);
-
$availableFilterColumnsSpecifies which columns of the resource are available for filtering. The default value is[], meaning clients can't filter by any column. List the columns that should be searchable, or set it tonullto allow any column (not recommended), e.g.:protected ?array $availableFilterColumns = ['is_admin', 'country'];
-
$availableScopesSpecifies which scopes are allowed to be applied via thebelongsToandrelationIdconfigurations. The default value is[], meaning clients can't apply any scope. List the scopes that should be allowed, or set it tonullto allow any scope (not recommended), e.g.:protected ?array $availableScopes = ['byTeam', 'published'];
-
$availableIncludesSpecifies which relationships are allowed to be included via theincludeQueryString parameter. The default value is[], meaning clients can't include any relationship. List the relationships that should be loadable, or set it tonullto allow any relationship (not recommended), e.g.:protected ?array $availableIncludes = ['comments', 'author', 'tags'];
-
$availableSortFieldsSpecifies which fields are allowed for sorting via thesortFieldQueryString parameter. The default value is[], meaning clients can't sort by any field. List the fields that should be sortable, or set it tonullto allow any field (not recommended). Any other field is ignored without raising an error, and the listing is returned without that ordering, e.g.:protected ?array $availableSortFields = ['name', 'created_at'];
You can also configure this using the
setAvailableSortFields()method:$service->setAvailableSortFields(['name', 'created_at']);
-
$defaultSortFieldand$defaultSortDirectionDefine the order applied when the client doesn't request a sort, or requests a field not allowed by$availableSortFields. The default values arenulland'asc'. The field doesn't need to be listed in$availableSortFields, e.g.:protected ?string $defaultSortField = 'created_at'; protected string $defaultSortDirection = 'desc';
You can also configure this using the
setDefaultSort()method:$service->setDefaultSort('created_at', 'desc');
-
$searchConfigurationHolds an array defining the search configuration for listings. This allows fine-grained customization of multi-parameter searches. The property stores the default configuration but can be overridden using thesetSearchConfiguration()method, which merges new settings with the existing defaults to adapt searches for specific listings. -
$maxFiltersSets the maximum number of filters allowed per query. The default value isnull, meaning no limit is enforced. When set to a positive integer, any number of active filters exceeding this value will be automatically capped to the configured maximum without raising an error. -
$maxIdsDefines the maximum number of IDs that will be returned when calling the allIds() method of the ResourceListable trait, preventing an excessive number of items from being returned that could overload the system. The default value is 100. It can be set to null, in which case there will be no limit. -
$requiredScopeRequires a specific scope to be provided in every listing query. When set to a scope name, thebelongsToparameter must match this scope exactly, otherwise aMissingRequiredScopeExceptionwill be thrown with a 403 Forbidden response. This is useful when a listing should always be scoped to a specific context (e.g., items always belonging to a team or user). The default value isnull, meaning no scope requirement is enforced. For security reasons, this is recommended when building APIs where certain resources should never be accessed without a specific scope applied. Usage example:protected ?string $requiredScope = 'byTeam'; // Or using the setter method: $service->setRequiredScope('byTeam');
When
$requiredScopeis set to'byTeam', every request tolist(),findIncluding()andallIds()must includebelongsTo=byTeamand arelationIdin the QueryString, or an exception will be thrown. InfindIncluding(), an element outside the scope gets a not found response.$requiredScopeis not an authorization mechanism. It only ensures that the query is filtered, but clients choose therelationIdthey send, so they can request the data of another team or user. To restrict what each user can see, enforce it increateQuery(), for example withwhere('team_id', Auth::user()->team_id), or with your middleware and policies.
Wrapping Results in a JsonResource
getResults() and findIncluding() return raw Eloquent models (or, when paginated, a paginator of models) unless $listResource is configured. Don't combine $listResource with these methods. If you want the same output shape your HTTP API already returns (e.g. reusing InvoiceResource from a console command, a queued job, or an MCP tool), ListService provides three explicit helper methods to wrap results into a Illuminate\Http\Resources\Json\JsonResource after fetching them:
wrapCollection(Collection $items, string $resourceClass)Wraps a plainCollection(result ofgetResults()with$paginated = false) into a$resourceClass::collection(...)call.wrapPaginated(array $results, string $resourceClass)Wraps the paginated array returned bygetResults()(['countItems' => ..., 'result' => $paginator]), preserving pagination metadata viaAbstractPaginator::through().wrapModel($model, string $resourceClass)Wraps a single model (result offindIncluding()). If$modelisnull(not found), it's returned untouched.
$service = new InvoiceListService(); // Paginated listing $results = $service->getResults(); $results = $service->wrapPaginated($results, InvoiceResource::class); // Non-paginated listing $service->setPaginated(false); $items = $service->getResults(); $items = $service->wrapCollection($items, InvoiceResource::class); // Single item $model = $service->findIncluding($id); $resource = $service->wrapModel($model, InvoiceResource::class);
The resources are returned unresolved (no ->resolve()/->toArray() call happens inside ListService), the same way models are returned unserialized today — resolving them to a response is left to the consumer (a controller returning them from an action, or an explicit ->resolve() call elsewhere).
If $resourceClass doesn't extend Illuminate\Http\Resources\Json\JsonResource, an InvalidResourceClassException is thrown immediately.
QueryString Configurations for Listing Operations
When working with the ResourceListable trait, listing configurations are retrieved from QueryString variables (sent via the URL). These configurations are automatically passed to the ListService to customize its behavior. This approach makes it easy to introduce numerous listing customizations that can change with each resource listing request.
Example of a listing query with QueryString variables:
https://example.com/api/users?sortField=email&sortDirection=desc&per_page=25&keyword=miss&filters[0][name]=is_admin&filters[0][active]=true&filters[0][value]=true
"keyword" Configuration
Allows sending a keyword to search the model for that keyword.
example.com/users?keyword=paul
This configuration doesn’t perform any search by itself. You must enable it in the ListService by overriding the applyKeywordFilter() method:
protected function applyKeywordFilter(?string $keyword): void { $this->whereContains($this->query, ['name', 'email'], $keyword); }
Recommendation: Delegate the search to the model using a scope. Here’s an example using a similar() scope:
protected function applyKeywordFilter(?string $keyword): void { $this->query->similar($keyword); }
You need to implement this scope with your query logic in the corresponding model. Here's an example implementation:
use EscuelaIT\APIKit\BuildsLikeQueries; class Post extends Model { use BuildsLikeQueries; public function scopeSimilar($query, $keyword) { return $this->whereContains($query, ['title', 'status'], $keyword); } }
Searching with LIKE safely
ListService, CustomFilter and any class using the EscuelaIT\APIKit\BuildsLikeQueries trait, such as a model, have two helpers for text searches:
whereContains($query, $columns, $value)Finds values containing the text (%text%). It can't use a regular index, so it can be slow on large tables.whereStartsWith($query, $columns, $value)Finds values starting with the text (text%). It can use a regular index, so prefer it when it fits your search.
$columns is a column name or an array of them. Both helpers:
- Escape
%,_and\, so a client searching for100%only finds that text. - Group several columns in parentheses, so the
ORconditions can't skip the constraints ofcreateQuery(). - Leave the query unchanged if the value is empty or not scalar.
- Are not case sensitive on PostgreSQL. On other engines it depends on the collation of the column and the engine's own rules.
Avoid writing
'%' . $value . '%'by hand: clients could use wildcards, so%would match every row.
"sortField" and "sortDirection" Configuration
These two configurations together define the order of search results.
example.com/users?sortField=name&sortDirection=desc
You can specify any field listed in $availableSortFields to achieve the desired listing order.
Valid sortDirection values are "asc" (ascending) and "desc" (descending). Any other value gets a 400 response.
Listings without a valid sortField use the default order configured with $defaultSortField. Don't define it with orderBy() in createQuery(), because that order would take priority over the one requested by the client.
To keep pagination stable, $identifierField is always added as the last sort criterion, in ascending order, so listings without any other order are sorted by it. This is skipped if the query defined in createQuery() uses distinct, groupBy or union, so those queries should define their own order.
"per_page" Configuration
Allows specifying a custom page size for the resource listing.
example.com/users?per_page=25
This is only considered if the resource is paginated. To disable pagination, set the $paginated boolean property to false in ListService:
protected bool $paginated = true;
If pagination is enabled, the default page size is 10. The value must be between 1 and 10000, otherwise the response is a 400.
Limiting Maximum Page Size: You can enforce a maximum page size using the $maxPerPage property. If a user requests a per_page value exceeding this limit, it will be automatically capped to the configured maximum without raising an error:
protected ?int $maxPerPage = 100;
For example, if $maxPerPage is set to 100 and a user requests per_page=500, the listing will return a maximum of 100 items per page. This helps prevent performance degradation from excessively large page requests.
"filters" Configuration
The "filters" configuration allows enabling any number of filters via an array. For each filter, the following data is expected:
active: Indicates whether the filter should be applied. Ifactiveis not received, the filter won’t be processed.name: Column for filtering.value: Value to search for in that column.
For a filter to be processed (with active: true), the column must be listed in the ListService’s $availableFilterColumns array, or that property must be null (allowing any column). The default value is [], so no column can be filtered until you list it. Custom filters don't depend on this array.
For enhanced privacy and security, avoid setting
$availableFilterColumnstonull, to prevent users from enabling unintended filters.
Example: If the QueryString filters array is:
[
{
"name": "is_admin",
"active": "true",
"value": "true"
},
{
"name": "country",
"active": "true",
"value": "Spain"
},
{
"name": "continent",
"active": "false",
"value": "Asia"
}
]
This would filter items where is_admin = true and country = Spain. The continent filter would be ignored because active is false.
Limiting Maximum Number of Filters: You can enforce a maximum number of filters that can be applied simultaneously using the $maxFilters property. If the number of active filters exceeds this limit, only the first filters up to the maximum will be applied without raising an error:
protected ?int $maxFilters = 5;
For example, if $maxFilters is set to 2 and a user sends 4 active filters, only the first 2 filters will be applied. This helps prevent performance issues from overly complex queries with too many filter conditions.
Custom Filters Implementation
Database column-based filtering is convenient but often insufficient. This package supports custom filters defined by developers.
To implement custom filters, follow these steps:
- Create a custom filter class.
- Register custom filters in the
ListService’scustomFilters()method. - Send the necessary data via QueryString to activate/configure the filter.
Custom filters provide full Eloquent power, allowing complex queries across model columns or related data without restrictions.
Creating a Filter Class
First, implement a class defining the custom filter behavior. This class must extend CustomFilter:
use EscuelaIT\APIKit\CustomFilter; class EuropeFilter extends CustomFilter { // ... }
The class must define a $filterName property with the filter’s name and an apply() method that receives an Illuminate\Database\Eloquent\Builder instance:
class SpanishOrBrazilianFilter extends CustomFilter { protected $filterName = 'hispanic_brazilian'; public function apply(Builder $query): void { $query->where(function (Builder $subQuery) { $subQuery ->where('nationality', 'Spanish') ->orWhere('nationality', 'Brazilian'); }); } }
The above filter would restrict results to items where nationality is Spanish or Brazilian.
To access the filter value from QueryString, use getFilterValue():
class TitleContainsFilter extends CustomFilter { protected $filterName = 'title_contains'; public function apply(Builder $query): void { $this->whereContains($query, 'title', $this->getFilterValue()); } }
Other available CustomFilter methods:
getFilterName(): Returns the filter name.getFilterData(): Returns complete filter data from QueryString.isFilterActive(): Indicates if the filter is active.
Registering Filters in customFilters()
In your ListService, override the customFilters() method to return an array of custom filter instances:
protected function customFilters(): array { return [new TitleContainsFilter()]; }
Sending Filter Data via QueryString
Include the filter data in the QueryString variables:
[
{
"name": "title_contains",
"active": true,
"value": "foo"
}
]
name: Matches the$filterNamedefined in the filter class.active: Must betrueto apply the filter (otherwise,apply()won’t be called).value: Data passed to the filter for processing.
belongsTo and relationId Configurations
Filtering configurations are designed to be dynamically modified by user input, allowing each resource listing request to have highly variable queries.
However, there are cases where you want to fix certain listing behaviors independently, without allowing users to modify them via filters. For example, you might want to display a listing showing only invoices for a specific client, where that client cannot be changed via filters. In these cases, you can use belongsTo and relationId configurations for fixed filtering.
Let’s examine how this property works with an example. Suppose you’re working with a User resource model. Users belong to a Team model via a BelongsToMany relationship:
public function teams(): BelongsToMany { return $this->belongsToMany(Team::class); }
In the User model, you can create a scope to filter users by a team identifier:
public function scopeByTeam($query, $teamId) { return $query->whereHas('teams', function($query) use ($teamId) { $query->where('team_id', $teamId); }); }
To activate this scope-based filtering via QueryString, send the following configurations:
belongsTovariable with value"byTeam"relationIdvariable as an integer
Example URL:
https://example.com/api/users?belongsTo=byTeam&relationId=2
This will activate a scope named byTeam on the resource model, passing 2 as the parameter, returning only users belonging to the team with id=2.
Restricting Allowed Scopes with $availableScopes
Similar to $availableFilterColumns, you can restrict which scopes are allowed to be applied via the belongsTo configuration by setting the $availableScopes property.
By default, $availableScopes is [], which means no scope can be applied via QueryString. Define which scopes are allowed:
protected ?array $availableScopes = ['byTeam', 'published'];
With this configuration, only the byTeam and published scopes can be applied. Any attempt to apply a different scope via QueryString will be ignored.
For enhanced security, avoid setting
$availableScopestonull, to prevent users from enabling unintended scopes that might expose sensitive data or cause undesired filtering behavior.
"include" Configuration
The "include" configuration allows eager loading of related entities in listing results. This is useful for including associated data (like comments on posts, or payments for users) without requiring additional queries.
example.com/posts?include=comments,author
You can specify multiple relationships separated by commas. The data will be eager-loaded using Laravel's with() method.
Restricting Allowed Includes with $availableIncludes
Similar to $availableFilterColumns and $availableScopes, you can restrict which relationships are allowed to be included via the include parameter by setting the $availableIncludes property.
By default, $availableIncludes is [], which means no relationship can be included via QueryString. Define which relationships are allowed:
protected ?array $availableIncludes = ['comments', 'author', 'tags'];
With this configuration, only the comments, author, and tags relationships can be included. Any attempt to include other relationships via QueryString will be ignored.
You can also set the available includes using the setAvailableIncludes() method:
$service = (new ListService()) ->setListModel(Post::class) ->setAvailableIncludes(['comments', 'author']) ->setSearchConfiguration($config);
Example URL with allowed includes:
https://example.com/api/posts?include=comments,author
This will return posts with their related comments and authors eager-loaded.
Example with restricted includes:
If $availableIncludes is set to ['comments'] but you request ?include=comments,author, only the comments relationship will be included. The author relationship will be ignored because it's not in the allowed list.
For enhanced security and performance, avoid setting
$availableIncludestonull. Withnull, clients can load any relationship, and only methods with a declared relationship return type (e.g.: HasMany) are accepted. Untyped relationships are ignored unless you list them in$availableIncludes. It also allows users to eager-load potentially expensive relationships that might impact application performance or expose sensitive data through related entities.
List Service methods
setSearchConfigurationValue
Sets a specific value in the search configuration array.
Description:
This method allows you to set individual properties of the $searchConfiguration array without needing to pass an entire array. It follows the fluent interface pattern, enabling method chaining with other service methods.
Parameters:
string $key- The configuration property name to set (e.g., 'keyword', 'perPage', 'sortField')mixed $value- The value to assign to the configuration property. If the value isnull, it will not be set.
Returns:
static - Returns the service instance for method chaining.
Example Usage:
$listService = new ListService(); $listService->setListModel(Post::class) ->setAvailableSortFields(['created_at']) ->setSearchConfigurationValue('keyword', 'laravel') ->setSearchConfigurationValue('perPage', 20) ->setSearchConfigurationValue('sortField', 'created_at') ->setSearchConfigurationValue('sortDirection', 'desc'); $results = $listService->getResults();
The $available* lists also apply to values set with this method, so created_at must be listed in $availableSortFields for the sort to be applied.
- This method provides a convenient alternative to
setSearchConfiguration()when setting a single value - Null values are ignored and will not update the configuration
- This method supports method chaining for a fluent API
setRequiredScope
Requires a specific scope to be provided in every query.
Description:
This method enforces that listing queries must include a specific scope (via the belongsTo QueryString parameter). If the required scope is not provided or doesn't match, a MissingRequiredScopeException is thrown. This is useful for APIs where certain resources should always be accessed within a specific context, such as items that always belong to a team or user.
Parameters:
?string $scopeName- The name of the required scope. Passnullto disable the scope requirement.
Returns:
static - Returns the service instance for method chaining.
Exceptions:
MissingRequiredScopeException- Thrown whengetResults(),findIncluding()orgetAllIds()is called and the required scope is not provided or doesn't match, orrelationIdis missing.
Example Usage:
class TeamInvoicesListService extends ListService { protected string $listModel = Invoice::class; protected ?string $requiredScope = 'byTeam'; // Property-based configuration } // Or using the setter method: $listService = new ListService(); $listService->setListModel(Invoice::class) ->setRequiredScope('byTeam') ->setAvailableScopes(['byTeam', 'byUser']) ->setSearchConfiguration([ 'belongsTo' => 'byTeam', 'relationId' => 123, 'perPage' => 10, ]); $results = $listService->getResults(); // Works - scope matches
If the scope doesn't match:
$listService->setRequiredScope('byTeam') ->setSearchConfiguration([ 'belongsTo' => 'byUser', // Different scope 'relationId' => 123, ]); $results = $listService->getResults(); // Throws MissingRequiredScopeException
Notes:
- The default value is
null, which means no scope requirement is enforced (backward compatible) - Validation only occurs if a required scope is explicitly set
- This feature works seamlessly with the
ResourceListabletrait - the scope should be passed via thebelongsToQueryString parameter - A request without
relationIdalso throws the exception, because the scope could not be applied - The required scope is always allowed, even if it is not listed in
$availableScopes - It also applies to
findIncluding()andgetAllIds(). Without a required scope,findIncluding()ignoresbelongsTo
$requiredScopeis not an authorization mechanism. It only ensures that the query is filtered, but clients choose therelationIdthey send, so they can request the data of another team or user. To restrict what each user can see, enforce it increateQuery(), for example withwhere('team_id', Auth::user()->team_id), or with your middleware and policies.
Resource Actions
Besides typical CRUD operations, applications often require additional behaviors to complete custom business operations. For example, for an invoice you might need an action to create a duplicate, or for a quote you might need an action to generate an invoice from it.
Additionally, some of these operations sometimes need to be performed in batches. For example, marking a series of invoices as paid, or sending a notification to a series of users, without having to perform the same action one by one.
To simplify these functionalities, the Laravel Api Kit package provides resource actions, a consistent way to handle common API operations for resources.
To build these actions, we need to define a couple of components:
- A single controller to receive all action execution requests on a model in a unified way.
- A service that defines how actions should be executed, indicating which model they operate on, what actions are possible, etc.
Additionally, configuring the actions requires defining the action itself, which contains the logic needed for its execution. Each action is implemented in its own dedicated class.
Action Controller
We will need a single controller to execute all necessary actions on an entity. This controller will receive the data to process the action via POST, such as the action type, the identifiers of the elements on which the action should be executed, and any additional data the action needs.
To define the controller, we will create a route like this:
Route::post('/users/action', ActionUsersController::class);
ActionHandler Trait
To easily implement the action controller, the ActionHandler trait is provided, which must be used in the controller that receives the action data to process.
namespace App\Http\Controllers\User; use EscuelaIT\APIKit\ActionHandler; class ActionUsersController extends Controller { use ActionHandler; }
Inside the controller, you must implement the method configured in the POST route and invoke the handleAction() method provided by the ActionHandler trait, passing an instance of the ActionService that defines the action system configuration.
public function __invoke() { $userActionService = new UserActionService(); return $this->handleAction($userActionService); }
The previous __invoke method is because we've chosen to use an invokable controller, although this is not a requirement for the action system to work.
For convenience, it's always useful to have the ActionService instance injected into the method, delegating to Laravel's service container the instantiation of the corresponding service.
public function __invoke(UserActionService $userActionService) { return $this->handleAction($userActionService); }
The controller expects to receive a payload like the following:
{
"type": "SetAdminUserAction",
"relatedIds": [
"3",
"5"
],
"data": {
"is_admin": false
}
}
type: the type of action that will be executedrelatedIds: the identifiers of the models on which the action will be executeddata: any set of data that the action requires to be processed
ActionService
To configure the action system for each entity, we use the ActionService class provided by the Laravel API Kit package.
The most common approach is to create our own service by extending the ActionService class and making the appropriate configurations.
namespace App\Services\User; use App\Models\User; use EscuelaIT\APIKit\ActionService; use EscuelaIT\APIKit\Actions\DeleteAction; class UserActionService extends ActionService { protected string $actionModel = User::class; protected array $actionTypes = [ 'DeleteAction' => DeleteAction::class, ]; }
ActionService Properties
The properties that can be configured in the custom ActionService are the following:
$actionModel Property
Allows defining the class of the model that will be used to process the actions.
protected string $actionModel = User::class;
$actionTypes Property
This is an associative array that defines the types of actions that can be performed on a model. In this array, the keys will be the action name, which corresponds to the type value received by the controller via POST. The value will be the action class responsible for processing it.
protected array $actionTypes = [ 'DeleteAction' => DeleteAction::class, 'SetAdminUserAction' => SetAdminUserAction::class, ];
$maxModelsPerAction Property
Allows specifying the maximum number of elements on which an action is allowed to be executed.
The default value of this property is 100, so if an action is requested to be executed on more than 100 elements, a validation error will occur, sending an HTTP 422 response code with a message.
protected int $maxModelsPerAction = 50;
$identifierField Property
This is the field in the model's table where matches will be looked for to determine which elements should be processed.
The default value is id. So it expects that when requesting action processing, the identifiers of the elements are sent in the relatedIds field sent via POST to the controller.
For example, it could be set to slug if what we will receive in the relatedIds field are the slugs of the elements on which we want to process the actions.
protected string $identifierField = 'slug';
ActionService Methods
Although not common, if you want to further customize the service's behavior, besides the previous properties, you can override methods that allow performing arbitrary actions.
createQuery(): initializes the queryqueryModels(): filters the models with the received identifiersgetModels(): returns the collection of models resulting from the query
For example, this would restrict the ActionService to execute actions only on users who are not administrators.
protected function createQuery() { return User::where('is_admin', false); }
With the following code in the createQuery() method, you can ensure that the listed items always belong to the company associated with the current user.
protected function createQuery() { $user = Auth::user(); throw_if(is_null($user), AuthenticationException::class); return $this->actionModel::query()->where('company_id', $user->company_id); }
Actions
To define particular behaviors for each type of action, we use action classes. Actions must extend the abstract CrudAction class.
The ActionHandler trait will call the corresponding action, deduced from the type value and according to the corresponding class in the $actionTypes array of the ActionService.
When building the action, three pieces of data will be available:
- The user authenticated in this request, or
nullif none exists. This user will be stored as the$userproperty. - The related models based on the query, available in the
$modelsproperty. - The data sent in the request to the controller in the
datafield of the payload. This data will be available in the$dataproperty.
With this data, an action can be defined as shown in the following example:
namespace App\Actions\User; use EscuelaIT\APIKit\CrudAction; use EscuelaIT\APIKit\ActionResult; class SetAdminUserAction extends CrudAction { protected function validationRules(): array { return [ 'is_admin' => 'nullable|boolean', ]; } public function handle(): ActionResult { foreach($this->models as $model) { if($this->user->can('update', $model)) { $model->is_admin = $this->data['is_admin']; $model->save(); } } return $this->createActionResultSuccess('Admin updated', [ 'new_value' => $this->data['is_admin'], ]); } }
The validationRules() method allows defining validation rules for the data set contained in the data field of the payload.
The handle() method is responsible for processing the action. Typically in this method, you iterate over each of the models in the $models property. For each model, you can check if the user has permission to perform the actions and then execute the action if possible.
Finally, the action must return a response by sending an instance of the ActionResult class. To facilitate creating the response in the appropriate format, the CrudAction class provides a utility method:
createActionResultSuccess($message, $data): Allows generating anActionResultinstance for a positive response, sending a response message and an array of data to be reported to the client that requested the action.
The action processing flow already provides all the mechanisms to validate the action data, both the action format itself and the format of the specific data required by each type of action. Therefore, it's normal that if the handle method is executed, the action can be processed. However, if for any reason additional checks are needed and extra errors must be sent, this can be done in the handle method using another utility method that allows producing an error response message.
createActionResultError($message, $errors): Allows generating anActionResultinstance for a negative response, sending a message and an associative array of errors.
The handle() method of the action must return the ActionResult instance, created by the createActionResultSuccess() or createActionResultError() methods. However, if these methods are not sufficient, you can also directly use the static success() or error() methods of the ActionResult class.
Action Response Formats
Once the action is processed, a response will be sent to the client. The format is consistent with other response formats from other types of API requests.
In case of success, a JSON will be sent to the client in this format:
{
"status": 200,
"message": "Admin updated",
"data": {
"msg": "Admin updated",
"action": "SetAdminUserAction",
"data": {
"new_value": false
}
},
"errors": [],
"execution": "84ms",
"version": "1"
}
In case of a validation error, the response format would be the following:
{
"status": 422,
"message": "The provided data is not valid.",
"errors": {
"foo": [
"The foo field is required."
]
},
"data": [],
"execution": "197ms",
"version": "1"
}
ActionResult Class
This class is used internally to create homogeneous action responses. It is prepared to create both positive and negative messages.
To create instances, the only available methods are the following:
success() Method
static function success(string $message = 'Ok', array $data = []): ActionResult
Returns an ActionResult instance configured as a success response.
$actionSuccessInstance = ActionResult::success('Action completed successfully', [ 'total_received' => 5, 'successfully_processed' => 4, ]);
error() Method
static function error(array $errors = [], string $message = 'Error'): ActionResult
Returns an ActionResult instance configured as an error response.
The $errors parameter expects an associative array of errors where the keys are the fields where the error occurred and the values are the specific errors found.
$actionErrorInstance = ActionResult::error([ 'count' => ['The field count must be a number'] ], $message);