adonyarik / consistent-api
Laravel toolkit for lean REST APIs: modular structure, JSON middleware, pagination and sorting.
Requires
- php: ^8.1
- laravel/framework: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel toolkit for lean, consistent REST APIs: modular route structure, CRUD controller, filtering and sorting, pagination, JSON/multipart middleware, debug responses, and PostgreSQL ENUM helpers.
| Requirement | Version |
|---|---|
| PHP | ^8.1 |
| Laravel | ^10 / ^11 / ^12 / ^13 |
Package: adonyarik/consistent-api
Namespace: Adonyarik\ConsistentApi
Table of contents
- Installation
- Configuration
- Modular structure
- Models (
CrudModel) - CRUD controller
- Search, filters, and sorting
- Pagination and responses
- Middleware
- Debugger
- Route macro
development - PostgreSQL ENUM
- Extra traits
- Package structure
Installation
composer require adonyarik/consistent-api
The service provider is registered via Laravel package discovery:
Adonyarik\ConsistentApi\ConsistentApiProvider
It automatically boots:
ModuleServiceProvider— module routes and theapirate limiterMacroServiceProvider—Route::development()PgEnumServiceProvider— PostgreSQL ENUM macros (migration context)
Publish the config files:
php artisan vendor:publish --tag=consistent-api-config
This creates:
config/consistentapi.phpconfig/pagination.php
Configuration
config/consistentapi.php
| Key | Default | Description |
|---|---|---|
modules_folder |
Modules |
Modules directory relative to app/ |
request_limit |
60 |
Max requests per minute for the api rate limiter |
api_url_prefix |
api |
URL prefix for module routes |
middlewares |
['api'] |
Middleware stack applied to module routes |
debugger_enabled |
env('DEBUGGER_ENABLED', false) |
Enable the debug block in JSON responses |
Example .env:
DEBUGGER_ENABLED=true
config/pagination.php
| Key | Default | Description |
|---|---|---|
data_container_name |
items |
Data array key in the response |
meta_container_name |
meta |
Pagination metadata key |
per_page |
sm/default/md/lg/xl → 10/15/25/50/100 |
Allowed perpage values |
Modular structure
The package loads modules from app/{modules_folder} (default: app/Modules).
Example layout:
app/Modules/
├── Routes.php # optional global API routes
├── Users/
│ └── Routes.php
└── Posts/
└── Routes.php
Each module Routes.php (and the optional root Routes.php) is loaded with:
- prefix from
consistentapi.api_url_prefix(e.g.api) - middleware from
consistentapi.middlewares(e.g.api)
A folder named Middleware inside the modules directory is skipped.
If the modules directory does not exist, the provider does not fail — routes are simply not loaded.
Rate limiting
On boot, the package registers a limiter named api:
Limit::perMinute(config('consistentapi.request_limit')) ->by($request->user()?->id ?: $request->ip());
This may override your application's default api limiter. Adjust request_limit, or redefine the limiter in your AppServiceProvider / bootstrap/app.php if needed.
Models (CrudModel)
Base API model:
use Adonyarik\ConsistentApi\Models\CrudModel; class Post extends CrudModel { protected array $filter = ['title', 'body']; protected array $sort = ['id', 'created_at', 'title']; protected $fillable = ['title', 'body']; }
Features:
CanFilterandCanSorttraitsHasFactorywith lookup forDatabase\Factories\{Model}FactoryleftJoinOnce()— left join without duplicatesgetAllColumns()— table column listing
Disable pagination for a model
Implement the contract:
use Adonyarik\ConsistentApi\Contracts\WithoutPaginationModelContract; class Setting extends CrudModel implements WithoutPaginationModelContract { // ... }
Then, with paginate=false (or 0), indexLogic returns the full list without pagination meta.
CRUD controller
Extend Adonyarik\ConsistentApi\Controllers\CrudController and set:
$resourceClass— API Resource class$relationFunctions— relations forwith/load(optional)
namespace App\Modules\Posts\Controllers; use Adonyarik\ConsistentApi\Controllers\CrudController; use App\Modules\Posts\Models\Post; use App\Modules\Posts\Requests\PostSearchRequest; use App\Modules\Posts\Requests\StorePostRequest; use App\Modules\Posts\Requests\UpdatePostRequest; use App\Modules\Posts\Resources\PostResource; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; class PostController extends CrudController { protected string $resourceClass = PostResource::class; protected array $relationFunctions = ['author']; public function index(PostSearchRequest $request): JsonResponse { return $this->indexLogic($request, new Post()); } public function show(Post $post): JsonResource { return $this->selectLogic($post); } public function store(StorePostRequest $request): JsonResponse { return $this->storeLogic($request, new Post()); } public function update(UpdatePostRequest $request, Post $post): JsonResource { return $this->updateLogic($request, $post); } public function destroy(Post $post): JsonResponse { return $this->destroyLogic($post); } }
Methods
| Method | Purpose | Response |
|---|---|---|
indexLogic |
List + filter/sort/paginate | PaginatedJsonResponse or non-paginated JSON |
selectLogic |
Single record | JsonResource |
storeLogic |
Create | 201 + resource |
updateLogic |
Update | JsonResource |
destroyLogic |
Delete | 204 No Content |
Models passed into these methods must extend CrudModel.
Search, filters, and sorting
Request
Use BaseSearchRequest or extend it:
use Adonyarik\ConsistentApi\Requests\BaseSearchRequest; class PostSearchRequest extends BaseSearchRequest { // add extra rules if needed }
Default rules:
| Parameter | Rules |
|---|---|
perpage |
numeric value from config('pagination.per_page') |
paginate |
true / false / 0 / 1 |
sort |
array |
sort.* |
asc or desc |
filter |
array |
filter.* |
any nullable value |
Example request
GET /api/posts?perpage=25&filter[title]=hello&sort[created_at]=desc
Behaviour
- Filtering:
LIKE/ILIKE(PostgreSQL) on columns allowed in$filter - Sorting:
orderByon columns allowed in$sort - Disallowed keys →
422with Laravel-style validation errors - If
filter/sortis sent but the model is not filterable/sortable →422
Whitelist on the model:
protected array $filter = ['title']; protected array $sort = ['id', 'created_at'];
An empty array means filtering/sorting is disabled.
Pagination and responses
PaginatedJsonResponse produces JSON like:
{
"items": [ /* resource collection */ ],
"meta": {
"current_page": 1,
"last_page": 3,
"from": 1,
"to": 15,
"total": 42,
"per_page": 15,
"path": "http://localhost/api/posts"
}
}
The items / meta keys are configurable in config/pagination.php.
Without pagination (contract + paginate=false):
{
"items": [ /* ... */ ]
}
Middleware
Aliases are registered automatically:
| Alias | Class | Purpose |
|---|---|---|
consistent.api-json |
ApiJsonMiddleware |
Sets Accept: application/json for API-prefixed URLs |
consistent.ensure-json |
EnsureJsonMiddleware |
Requires JSON Content-Type for POST / PUT / PATCH |
consistent.ensure-multipart |
EnsureMultipartMiddleware |
Requires multipart/form-data for POST |
consistent.debugger |
DebuggerMiddleware |
Appends a debugger block to JSON responses |
Example usage
Laravel 11+:
// bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->appendToGroup('api', [ \Adonyarik\ConsistentApi\Middleware\ApiJsonMiddleware::class, ]); })
Or in routes:
Route::middleware(['consistent.ensure-json'])->group(function () { // ... }); Route::post('/files', UploadController::class) ->middleware('consistent.ensure-multipart');
Attach EnsureMultipartMiddleware only to upload endpoints: any non-POST request or missing multipart Content-Type returns 415.
Debugger
- Set
DEBUGGER_ENABLED=true - Apply the
consistent.debuggermiddleware to the routes/group you need
JSON responses will include:
{
"items": [],
"meta": {},
"debugger": {
"id": "dbg_...",
"datetime": "2026-09-05 21:00:00",
"executionTime": 0.012,
"method": "GET",
"uri": "/api/posts",
"clientIP": "127.0.0.1",
"memoryUsage": "4.2 MB",
"router": "App\\Modules\\Posts\\Controllers\\PostController@index",
"inputs": {},
"db": {
"queryCount": 2,
"list": [
{ "sql": "...", "bindings": [], "time": 0.5 }
]
}
}
}
Do not enable the debugger in production unless you intend to expose SQL, bindings, and request input.
Route macro development
Routes available only in the local environment:
use Illuminate\Support\Facades\Route; Route::development(function () { Route::get('/api/_debug/ping', fn () => ['ok' => true]); });
In production / staging the callback is not executed.
PostgreSQL ENUM
Macros are active during migrations (artisan migrate*) and tests (pest / phpunit).
DB macros
DB::pgsqlCreateEnumType('post_status', ['draft', 'published', 'archived']); DB::pgsqlChangeEnum('posts', 'status', 'post_status'); DB::pgsqlAlterEnumValues('post_status', ['draft', 'published', 'archived', 'deleted']); DB::pgsqlChangeEnumWithDefault('posts', 'status', 'post_status', ['draft', 'published'], 'draft'); DB::pgsqlDropEnumType('post_status');
Blueprint macros
Schema::create('posts', function (Blueprint $table) { $table->id(); $table->pgsqlCreateEnum('status', 'post_status', ['draft', 'published']); // or, if the type already exists: // $table->pgsqlEnum('status', 'post_status'); $table->pgsqlSetEnumDefault('status', 'post_status', 'draft'); $table->timestamps(); });
Failures throw Adonyarik\ConsistentApi\Exceptions\PgEnumException.
Extra traits
EnumHelpers
For PHP backed enums:
use Adonyarik\ConsistentApi\Traits\EnumHelpers; enum PostStatus: string { use EnumHelpers; case Draft = 'draft'; case Published = 'published'; } PostStatus::names(); // ['Draft', 'Published'] PostStatus::values(); // ['draft', 'published'] PostStatus::toArray(); // ['Draft' => 'draft', ...]
Credibility
Assert that a related model “belongs” to the current one (matching IDs):
use Adonyarik\ConsistentApi\Traits\Credibility; class Comment extends CrudModel { use Credibility; public function ensurePost(Post $post): void { $this->checkModelCredibility($post, 'post_id'); // 404 on mismatch } }
Package structure
consistent-api/
├── composer.json
├── config/
│ ├── consistentapi.php
│ └── pagination.php
└── src/
├── ConsistentApiProvider.php
├── Contracts/
│ └── WithoutPaginationModelContract.php
├── Controllers/
│ ├── Controller.php
│ └── CrudController.php
├── Exceptions/
│ └── PgEnumException.php
├── Middleware/
│ ├── ApiJsonMiddleware.php
│ ├── DebuggerMiddleware.php
│ ├── EnsureJsonMiddleware.php
│ └── EnsureMultipartMiddleware.php
├── Models/
│ └── CrudModel.php
├── Providers/
│ ├── DebuggerServiceProvider.php # debug service (not a Laravel SP)
│ ├── MacroServiceProvider.php
│ ├── ModuleServiceProvider.php
│ └── PgEnumServiceProvider.php
├── Requests/
│ └── BaseSearchRequest.php
├── Responses/
│ └── PaginatedJsonResponse.php
└── Traits/
├── CanFilter.php
├── CanSort.php
├── Credibility.php
└── EnumHelpers.php
Quick start checklist
composer require adonyarik/consistent-apiphp artisan vendor:publish --tag=consistent-api-config- Create
app/Modules/{Name}/Routes.php - Extend models from
CrudModeland define$filter/$sort - Extend controllers from
CrudControllerand set$resourceClass - Optionally add middleware aliases to your
apigroup - For debugging:
DEBUGGER_ENABLED=true+consistent.debugger
License
MIT © Yaroslav Tyrchenko