laravel-steward maintained by hatchyu
Laravel Steward
Production-grade CQRS primitives, declarative HTTP query parsing, and native JSON:API stewardship for Laravel applications.
Overview
Laravel Steward is a lightweight, high-performance architecture toolkit designed for modern Laravel applications following CQRS principles. It provides clean abstractions for encapsulate-and-execute write actions, declarative read queries, strict request query parameter validation, and seamless JSON:API resource mapping built directly on top of Laravel's native HTTP resource system.
Key Features
- Encapsulated Write Actions: Robust base classes for create, update, and delete actions featuring unconditional database transaction savepoints (
SAVEPOINT) and post-commit callback hooks (afterCommit). - Declarative Model Queries: Fluent, type-safe read query builders supporting single-model lookups (
find,byId,byIdsOrFail) and multi-criteria list queries. - Universal Query Parameter Parsers: Flexible HTTP query string and JSON body parsers for search, multi-field filtering (including comma-separated lists), sorting, and cursor or offset pagination.
- Native JSON:API Integration: Resource primitives leveraging Laravel's native
JsonApiResourcewithout heavy third-party dependencies likeleague/fractal. - Query-Side Relation Projection: Auto-derived eager loading and sparse fieldset selection directly driven by JSON:API resource metadata.
- Bounded Request Validation: Dedicated request validation rules (
FilterQueryRule,SortQueryRule,IncludeQueryRule,FieldsQueryRule,PageQueryRule) sharing unified complexity and length limits (QueryParamLimits).
[!IMPORTANT] Security & Authorization Boundaries
laravel-stewardis a CQRS data orchestration and query engine, not an authorization or multi-tenancy boundary.
- Authorization: Always perform Policy checks (
$this->authorize(...)/Gate::authorize()) in your Controllers or Form Requests before passing models to Steward Actions.- Tenant Scoping: Ensure tenant-scoped global scopes or explicit query scoping (e.g.,
$user->team->customers()) are applied prior to executing Steward Queries or Actions. Steward assumes the model or query passed into its pipeline has already been authorized and scoped by your application.
Requirements
- PHP:
^8.4 - Laravel:
^12.0(Steward builds upon Laravel 12's nativeJsonApiResourcesystem)
Installation
Install the package via Composer:
composer require hatchyu/laravel-steward
Optionally publish the configuration file:
php artisan vendor:publish --tag="steward-config"
The package registers:
Hatchyu\Steward\Queries\Contracts\QueryParamsParserContractHatchyu\Steward\Queries\Contracts\QueryParamsProcessorContract
through StewardServiceProvider.php.
Query parsing
QueryParamsParserContract auto-detects plain query strings vs JSON:API query format.
Plain format example:
GET /customers?search=alice&filter[is_active]=1&sort=-created_at&page=2&size=25
Plain format also accepts filter as a JSON object string (unencoded / URL-encoded):
# Unencoded (Readable JSON string)
GET /customers?filter={"is_active":1,"gender":"female"}
# URL-encoded (Standard HTTP client payload)
GET /customers?filter=%7B%22is_active%22%3A1%2C%22gender%22%3A%22female%22%7D
JSON:API format example:
GET /customers?filter[search]=alice&sort=-created_at&page[number]=2&page[size]=25
JSON:API format also accepts filter as a JSON object string:
# Unencoded (Readable JSON string)
GET /customers?filter={"search":"alice","is_active":1}&sort=-created_at&page[number]=2&page[size]=25
# URL-encoded (Standard HTTP client payload)
GET /customers?filter=%7B%22search%22%3A%22alice%22%2C%22is_active%22%3A1%7D&sort=-created_at&page[number]=2&page[size]=25
Notes:
- Bracket-style query params (
filter[field]=...) are usually easier to read and operate. - JSON-string filters are supported for clients that already serialize query payloads that way.
Query Parameter Conventions
laravel-steward uses standard, fixed query parameter conventions across its parsers:
| Parameter | Type | Format / Example | Description |
|---|---|---|---|
search |
string |
?search=john |
Free-text search string. In JSON:API format, ?filter[search]=john is also supported. |
filter |
array|string |
Single value: ?filter[status]=activeComma-separated: ?filter[id]=1,2,3,4,5Array list: ?filter[id][]=1&filter[id][]=2JSON string: ?filter={"status":"active"} |
Filter criteria. Supports single scalars, array lists, comma-separated strings (1,2,3), or JSON object strings. |
sort |
string|array |
Comma-separated: ?sort=-created_at,nameJSON array: ?sort=["-created_at","name"] |
Comma-separated string, array list, or JSON array string. Prefix - denotes descending order (desc). |
include |
string |
?include=addresses,orders.items |
Comma-separated list of relationship inclusion paths. |
fields |
array|string |
Bracket object: ?fields[customers]=name,emailJSON object: ?fields={"customers":["name","email"]} |
Object keyed by JSON:API resource type specifying sparse fieldsets. Supports bracket syntax or JSON object strings. |
page |
array|string|integer |
Plain: ?page=2&size=25JSON:API: ?page[number]=2&page[size]=25JSON object: ?page={"number":2,"size":25} |
Page-number or cursor pagination. JSON:API requests require the page-object form. |
page[cursor] |
string |
?page[cursor]=eyJpZCI6MTB9 |
Cursor token string for cursor-based pagination. |
size |
integer |
?size=25 |
Top-level fallback parameter for page size. |
Pagination and request-complexity limits are configured in steward.php. Defaults cap page size at 100, page number at 10,000, filter fields and sort fields at 20, values per filter at 100, includes at 20, requested sparse fields at 100, and individual query values at 2,048 bytes. Invalid or oversized values are rejected with validation errors; they are never silently ignored or clamped.
Convert a request into normalized query params:
use Hatchyu\Steward\Requests\ListQueryRequest;
final class ListCustomersRequest extends ListQueryRequest
{
}
$params = ListCustomersRequest::from($request)->toQueryParams();
The normalized result is Hatchyu\Steward\Queries\Params\QueryParams.
It includes:
searchfilterssortincludesfieldspagesize
For safety, list endpoints reject include and sparse fields[...] parameters unless the query or request declares them explicitly. When using StewardResource, its resource metadata supplies those allowlists automatically. Non-JSON:API queries can override GetModelQuery::allowedIncludes().
Query definitions
Use QueryDefinition to declare which fields can be searched, filtered, and sorted.
Simple strings work for common cases:
use Hatchyu\Steward\Queries\Params\QueryDefinition;
new QueryDefinition(
searchable: ['name', 'email'],
filterable: ['is_active', 'gender'],
sortable: ['name', 'created_at'],
);
For mapped names or custom behavior, pass criteria objects:
use Hatchyu\Steward\Queries\Criteria\Filters\SimpleFilter;
use Hatchyu\Steward\Queries\Criteria\Searches\EndsWithSearch;
use Hatchyu\Steward\Queries\Criteria\Searches\PartialSearch;
use Hatchyu\Steward\Queries\Criteria\Searches\SimpleSearch;
use Hatchyu\Steward\Queries\Criteria\Searches\StartsWithSearch;
use Hatchyu\Steward\Queries\Criteria\Sorts\SimpleSort;
use Hatchyu\Steward\Queries\Params\QueryDefinition;
new QueryDefinition(
searchable: [new PartialSearch('term', 'users.email')],
filterable: [new SimpleFilter('status', 'users.is_active')],
sortable: [new SimpleSort('latest', 'users.created_at')],
);
Additional search strategies are also available for finer control:
new QueryDefinition(
searchable: [
new SimpleSearch('email_exact', 'email'),
new StartsWithSearch('name_prefix', 'name'),
new EndsWithSearch('domain_suffix', 'email'),
],
);
PartialSearch, StartsWithSearch, and EndsWithSearch escape SQL LIKE wildcard characters (% and _) before querying.
Model queries
Use GetModelQuery or FindModelQuery for read flows.
Example:
use App\Domain\Models\Customer;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Hatchyu\Steward\Queries\Contracts\QueryParamsProcessorContract;
use Hatchyu\Steward\Queries\GetModelQuery;
use Hatchyu\Steward\Queries\Params\QueryDefinition;
use Hatchyu\Steward\Queries\Params\QueryParams;
use Override;
final class ListCustomersQuery extends GetModelQuery
{
public function __construct(QueryParamsProcessorContract $processor)
{
parent::__construct(new Customer(), $processor);
}
public function execute(QueryParams $params): LengthAwarePaginator
{
$query = $this->query();
$this->applyQueryParams($query, $params);
return $this->paginateByQueryParams($query, $params);
}
#[Override]
protected function definition(): QueryDefinition
{
return new QueryDefinition(
searchable: ['name', 'email'],
filterable: ['is_active'],
sortable: ['created_at'],
);
}
#[Override]
protected function jsonApiResource(): ?string
{
return CustomerResource::class;
}
}
When jsonApiResource() is configured, applyQueryParams() will:
- validate requested
includepaths against the resource metadata - validate sparse fieldsets against the same resource metadata
- apply
with(...)eager-loading for those includes - apply conservative top-level
select(...)projection from sparse fieldsets
Projection rules:
- the model primary key is always selected
- only the primary resource type is projected at query level
- included resource fieldsets can also project eager-loaded relation queries when the relation is supported
- if a field does not map cleanly to database columns, omit it from
jsonApiFieldColumnMap()
Relation-aware eager-load projection rules:
- the related model primary key is always selected
- relation matching keys required by Eloquent are preserved automatically
- nested includes preserve the keys needed for their next eager-load hop
- unsupported or risky relation types can fall back to default eager loading
Current conservative behavior:
-
top-level projection is always supported
-
eager-load projection is intended for common direct relations such as
hasOne,hasMany,belongsTo, and morph variants -
belongsToManyis intentionally left conservative and falls back to the normal eager-load query -
CreateModelAction -
UpdateModelAction(supports bothexecute($id, $data)andexecuteModel($model, $data)) -
DeleteModelAction(supports bothexecute($id)andexecuteModel($model)) -
base abstractions for custom actions
These are designed to pair with AbstractData request DTOs. When you already have an instantiated model (for example, from Route Model Binding or policy authorization), call executeModel() to avoid an unnecessary database lookup.
Model Finders (FindModelQuery)
FindModelQuery provides point-lookups by primary key:
byId(int|string $id)/find(int|string $id)byIdOrFail(int|string $id)/findOrFail(int|string $id)byIds(array $ids)/findMany(array $ids)byIdsOrFail(array $ids)/findManyOrFail(array $ids): ensures all requested primary keys exist in the database, throwingModelIdsNotFoundExceptionwith the list of missing IDs if any key cannot be found.
Cursor Pagination
GetModelQuery supports both standard offset pagination (paginate()) and constant-time cursor pagination (cursorPaginate()):
$params = ListCustomersRequest::from($request)->toQueryParams();
// Cursor pagination using page[cursor] or ?cursor=...
return $query->cursorPaginateByQueryParams($queryBuilder, $params);
When using cursorPaginateByQueryParams(), Steward appends the model primary key as a tie-breaker when the requested sort does not already include it. This keeps cursor traversal stable when a client sorts on non-unique values.
Data objects
AbstractData lets you map validated input into typed DTO-style objects.
It normalizes booleans, backed/unit enums, and concrete int, float, and string constructor properties. Validate request data before constructing DTOs; ambiguous union types intentionally retain PHP's native type resolution.
Use it for action payloads and request body transformation. The test suite includes examples of case conversion and nullable handling under tests/Unit/.
Steward Resources (StewardResource)
Extend Hatchyu\Steward\Resources\StewardResource for API response documents.
StewardResource provides smart auto-detection defaults and format-neutral methods that serve both Standard REST and JSON:API formats:
Smart Conventions & Auto-Detection:
resourceType()Auto-Detection: Automatically derived from the class basename by removing"Resource"and converting to plural kebab-case (e.g.,CustomerAddressResource➔'customer-addresses',CustomerResource➔'customers'). Explicitly override only when custom naming is required.allowedFields(): Declares field names permitted for sparse fieldset filtering (?fields[customers]=name,email).fieldColumnMap(): Auto-mapped fromallowedFields()(field => [field]) unless overridden for custom database column names.allowedIncludes(): Declares relationship inclusion paths permitted for?include=...parameter query validation and automatic eager-load projection.toAttributes(Request $request): Declares scalar attribute properties. Loaded relationships defined inallowedIncludes()are automatically rendered byStewardResourcefor both REST and JSON:API modes without requiring manual relationship formatting intoAttributes().
Example Resource Implementation:
use Hatchyu\Steward\Resources\StewardResource;
use Illuminate\Http\Request;
use Modules\Customer\Domain\Enums\CustomerGender;
use Override;
final class CustomerResource extends StewardResource
{
// resourceType() is AUTO-DERIVED as 'customers' automatically!
#[Override]
public static function allowedFields(): array
{
return ['name', 'email', 'phone', 'notes', 'is_active', 'gender', 'created_at', 'updated_at'];
}
#[Override]
protected static function allowedIncludes(): array
{
return [
'addresses' => CustomerAddressResource::class,
];
}
#[Override]
public function toAttributes(Request $request): array
{
/** @var CustomerGender|string|null $gender */
$gender = $this->gender;
return [
'name' => $this->name,
'email' => $this->email,
'phone' => $this->phone,
'notes' => $this->notes,
'is_active' => (bool) $this->is_active,
'gender' => $gender instanceof CustomerGender ? $gender->value : $gender,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
Return a single resource:
return new CustomerResource($customer);
Return a collection:
return CustomerResource::collection($customers);
Both single resource and collection responses set the matching HTTP headers (Content-Type: application/json for REST mode, Content-Type: application/vnd.api+json for JSON:API mode).
JSON:API request validation
ListQueryRequest can validate:
filteras either an array or a JSON object stringsortpageincludefields[...]
The preferred approach is to point the request at the primary JSON:API resource:
use App\Http\Resources\CustomerResource;
use Hatchyu\Steward\Requests\ListQueryRequest;
use Override;
final class ListCustomersRequest extends ListQueryRequest
{
#[Override]
protected function primaryStewardResource(): ?string
{
return CustomerResource::class;
}
}
From that resource, the request derives:
- allowed
includepaths - allowed sparse fieldsets for the primary and included resources
The matching query can derive eager-loading from the same resource by overriding jsonApiResource().
That same resource metadata also powers conservative sparse-field projection for:
- the primary query
- eager-loaded included relations
If needed, you can still override allowedIncludes() and allowedFields() directly.
Dual REST & JSON:API Format Support Engine
laravel-steward provides a built-in, configurable dual format engine supporting both Standard REST and JSON:API formats for incoming write payloads and outgoing HTTP responses transparently.
1. Format Resolution (ApiResponseFormatResolver)
The outgoing format is determined dynamically per HTTP request:
- Forced Middleware Attribute: Handled via
steward_api_response_formatrequest attribute. - Query Parameter: e.g.,
?format=jsonapior?format=rest(configurable key insteward.api.query_parameter). - HTTP Accept Header:
Accept: application/vnd.api+jsonyields JSON:API format. - Configuration Default: Falls back to
config('steward.api.default_format', 'rest').
2. Payload Normalization (JsonApiPayloadNormalizer)
Incoming write payloads (POST, PUT, PATCH) are processed in BaseActionRequest::prepareForValidation():
- REST Payloads (
{ "name": "Acme Corp" }) pass through unmodified. - JSON:API Payloads (
{ "data": { "attributes": { "name": "Acme Corp" }, "relationships": { "roles": { "data": [{ "type": "roles", "id": "admin" }] } } } }) are automatically normalized into flat validated attributes (['name' => 'Acme Corp', 'roles' => ['admin']]). - Null Relationships (
{ "data": { "relationships": { "company": { "data": null } } } }) are converted tocompany_id => nullto cleanly disassociate relationships.
This allows Form Requests (BaseActionRequest), AbstractData DTOs, and CQRS Actions to remain 100% format-agnostic.
3. Dual Response Formatting (StewardResource & AnonymousResourceCollection)
Extending StewardResource automatically renders:
- REST Mode (Default):
{ "id": 1, "name": "Acme Corp" }withContent-Type: application/json. - JSON:API Mode: Full JSON:API document (
data,relationships,included,meta) withContent-Type: application/vnd.api+json.
Both single resources and collections (CustomerResource::collection($customers)) automatically set the matching Content-Type header via AnonymousResourceCollection.
4. Format Enforcement Middleware
Hatchyu\Steward\Middleware\EnforceApiRequestFormat: Rejects invalid payloads on state-changing requests (POST,PUT,PATCH) with415 Unsupported Media Typewhen strict JSON:API is required.Hatchyu\Steward\Middleware\EnforceApiResponseFormat: Forces specific routes/groups to render in JSON:API or REST format regardless of client overrides.
Design notes
- The package favors Laravel-native primitives over third-party transport layers.
- Query parsing, query execution, request validation, and response formatting stay separate.
- JSON:API capability is declared near the resource, then reused by request validation.
Test Examples in this Repository
See the working integration and test suite in: