nycorp / lite-api
The skeleton application for the Laravel framework.
Requires
- php: ^8.1
- darkaonline/l5-swagger: ^8.6
- dcarbone/php-consul-api: ^2.0
- laravel/scout: ^11.5
- reliese/laravel: ^1.4
- tymon/jwt-auth: ^2.1
Requires (Dev)
- bennett-treptow/laravel-migration-generator: ^4.4
- laravel/pint: ^1.16
- orchestra/testbench: ^9.1
- pestphp/pest: ^2.34
- phpstan/phpstan: ^1.11
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 1.6.3
- 1.6.2
- 1.6.1
- 1.6.0
- 1.5.0
- 1.4.0
- 1.3.2
- 1.3.1
- 1.3.0
- 1.2.3
- 1.2.2
- 1.2.1
- 1.2.0
- 1.1.58
- 1.1.57
- 1.1.56
- 1.1.55
- 1.1.54
- 1.1.53
- 1.1.52
- 1.1.51
- 1.1.50
- 1.1.49
- 1.1.48
- 1.1.47
- 1.1.46
- 1.1.45
- 1.1.44
- 1.1.43
- 1.1.42
- 1.1.41
- 1.1.40
- 1.1.39
- 1.1.38
- 1.1.37
- 1.1.36
- 1.1.35
- 1.1.34
- 1.1.33
- 1.1.32
- 1.1.3.1
- 1.1.3
- 1.1.2
- 1.1.1
- 1.1.0
- 1.0.9
- 1.0.8
- 1.0.7
- 1.0.6
- 1.0.5
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
This package is auto-updated.
Last update: 2026-10-27 05:39:00 UTC
README
- Log into database and into micro-service
- Swagger documentation using darkaonline/l5-swagger
- Service discovery using dcarbone/php-consul-api
- Jwt auth using tymon/jwt-auth
- SQL to migration using bennett-treptow/laravel-migration-generator
- SQL to model using reliese/laravel
- API versioning (Meta Graph API style): frozen versions, deprecation and sunset headers, one response envelope and one OpenAPI documentation per version, contract tests
Installation (with Composer)
composer require nycorp/lite-api
Usage
php artisan vendor:publish --provider="Nycorp\LiteApi\Providers\LiteApiServiceProvider"
Logging :
Enable logger to call your remote logger service or log in app database
the default value is false
LOG_REMOTE=false
To enable log service in .env
LOG_CHANNEL=stack LOG_STACK=daily,service_log LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug
use the logger provide by Laravel as usual
Log::info("Delete: started {$this->modelLogger($id)} ");
API versioning
A version (v1.0, v2.0...) is a frozen contract: request formats, response formats, behaviour and webhook payloads.
Old versions keep working untouched while new ones evolve.
| Allowed in a released version | Needs a new version |
|---|---|
| New endpoint, new optional input field | Removing or renaming a field, changing its type/format |
| New field in a response, new webhook event type | Making a field required, changing an error code/default |
1. Declare the versions
php artisan vendor:publish --tag=lite-api-versioning
// config/lite-api.php 'versions' => [ 'v1.0' => [ 'envelope' => LegacyEnvelope::class, // historical format, always HTTP 200 'released_at' => '2025-01-01', 'deprecated_at' => '2027-01-01', // announced to clients from now on 'sunset_at' => '2027-07-01', // HTTP 410 from this date 'changelog' => 'https://example.com/docs/changelog#v1.0', ], 'v2.0' => [ 'envelope' => StandardEnvelope::class, // real HTTP statuses 'released_at' => '2026-11-01', ], ],
The status is computed from the dates: active, deprecated once deprecated_at is reached, sunset once sunset_at is reached.
2. Register the routes
use Nycorp\LiteApi\LiteApi; // /api/v2.0/messages/... LiteApi::version('v2.0', function () { MessageController::expose('messages'); }); // Existing unversioned routes: tag them as v1.0 without moving them LiteApi::version('v1.0', base_path('routes/api.php'), prefix: ''); // GET /api/versions : declared versions and their lifecycle LiteApi::versionsRoute();
Every versioned response carries X-Api-Version (configurable). Once deprecated_at / sunset_at are set,
it also carries Deprecation (RFC 9745), Sunset (RFC 8594) and Link: <changelog>; rel="deprecation".
A sunset version answers 410 with the code VERSION_SUNSET (1200) and the latest version in the body.
3. Response envelopes
liteResponse() delegates to the envelope of the version serving the request: controllers do not change.
Outside versioned routes (unversioned routes, jobs, console) versioning.default_envelope is used (legacy).
LegacyEnvelope: unchanged historical format.
{"success": false, "message": "...", "code": 1002, "body": {...}, "trace_id": "..."}
StandardEnvelope: HTTP status from the response code (422 validation, 404 not found, 401 token, 409 duplicate...,
overridable with lite-api.http_status).
{"data": {...}}
{"error": {"code": 1002, "type": "REQUEST_VALIDATION_ERROR", "message": "...", "details": {...}, "trace_id": "..."}}
The envelope also applies to responses built before the version middleware (auth middlewares...), the version is read from the route.
Custom envelope: implement Nycorp\LiteApi\Response\Envelopes\ResponseEnvelope.
DefResponse reads both envelopes back (isSuccess(), getBody(), getMessage(), getCode()), so CoreController
and BaseService work the same whatever the envelope of the version.
4. Track usage before sunsetting
Nycorp\LiteApi\Events\ApiVersionUsed (version, request, HTTP status) is fired on every versioned request.
Listen to it to know which clients still call a version before deprecating it; storage is up to the application.
5. One OpenAPI documentation per version
Give a version its own annotation folder; an l5-swagger documentation is registered for it automatically
(api-docs-v2.0.json, UI on api/docs/v2.0), with the envelope schemas (ApiEnvelope, ApiError).
'v2.0' => [ 'envelope' => StandardEnvelope::class, 'docs' => [ 'annotations' => [app_path('Http/Docs/V2')], 'route' => 'api/docs/v2.0', // optional 'title' => 'My API', // optional, version appended 'base' => env('APP_URL') . '/api/v2.0', // optional, replaces l5-swagger paths.base for this version ], ],
- Each version folder holds its own
#[OA\Info(version: '2.0')]and#[OA\Server(url: '.../api/v2.0')]. - Annotations common to all versions go in
versioning.docs.shared_annotations. - The folders of the other versions are excluded automatically. If an existing documentation scans the whole
appfolder, add the version folders to itspaths.excludes. - Documentations declared by hand in
config/l5-swagger.phpwith the same name win.
php artisan l5-swagger:generate v2.0
Document a response in the legacy envelope with a typed body, so generated docs show its real shape:
use Nycorp\LiteApi\Docs\LegacyEnvelopeResponse; #[OA\Get(path: '/agents', responses: [new LegacyEnvelopeResponse(body: 'Agent', paginated: true)])] // body: 'Agent' | list: true (Agent[]) | paginated: true (ApiPage of Agent) // with: [new OA\Property(...)] extra body properties | schema: new OA\Schema(...) any other body
6. Contract tests
Freeze the shape of each endpoint once a version is released:
use Nycorp\LiteApi\Testing\AssertsApiContract; class AgentApiTest extends TestCase { use AssertsApiContract; public function test_search_contract(): void { $this->assertApiContract($this->getJson('/api/v2.0/agent/search'), 'v2.0/agent.search'); } }
The first run records tests/Contracts/v2.0/agent.search.json (commit it). Later runs fail when a field is removed,
changes type or becomes null; new fields pass. Re-record with LITE_API_UPDATE_CONTRACTS=1 only when opening a new version.
Outbound webhooks
Opt-in: nothing is migrated until you publish the migrations.
php artisan vendor:publish --tag=lite-api-webhooks php artisan migrate
Endpoints belong to an owner (company, account...): add HasWebhookEndpoints to that model.
1. Declare and dispatch events
// config/lite-api.php 'webhooks' => [ 'header_prefix' => 'X-MyApp', // X-MyApp-Event, -Delivery, -Timestamp, -Signature, -Api-Version 'events' => [ 'message.received' => 'A contact sent a message', ], ],
class MessageReceived extends WebhookEvent { public function __construct(private Message $message) {} public function type(): string { return 'message.received'; } public function owner(): Model { return $this->message->company; } // Endpoints can restrict themselves to some values through their `filters` public function context(): array { return ['app' => $this->message->app->hashid]; } // Payload per API version: endpoints keep the version they were created with public function data(?string $apiVersion): array { return MessagePresenter::present($this->message); } } Webhooks::dispatch(new MessageReceived($message)); // waits for the DB commit
Each subscribed endpoint gets a delivery, queued on the webhooks queue (run a worker for it).
2. Delivery
- Body:
{"id": "evt_…", "type": "...", "api_version": "v2.0", "created_at": "...", "data": {...}} - Signature:
sha256=hex(hmac_sha256(secret, "{timestamp}.{raw body}")); receivers can useWebhookSigner::verify() - Success: 2xx within
timeoutseconds, redirects are not followed - Retries:
backoff(10s, 1min, 5min, 30min, 2h, 6h, 12h), thenWebhookDeliveryFailed - An endpoint failing for
disable_after_hours(72h) is disabled:WebhookEndpointDisabled(notify the owner) - SSRF: URLs resolving to private/reserved addresses are refused on save and before each delivery
(
allow_private_urlsfor local development only) - History:
webhook_deliveries, pruned afterretention_days(schedulemodel:prune)
3. Management API
Inside an authenticated (versioned) group; the owner is the authenticated user by default
(WebhookEndpointController::resolveOwnerUsing() to change it).
LiteApi::version('v2.0', function () { LiteApi::webhookRoutes(); // /webhooks... });
| Route | |
|---|---|
GET webhooks/events |
Event catalog |
GET/POST webhooks, GET/PUT/DELETE webhooks/{id} |
Endpoints (secret returned once, on creation) |
POST webhooks/{id}/rotate-secret |
New secret |
POST webhooks/{id}/test |
Sends webhook.test now |
GET webhooks/{id}/deliveries |
History (?status=pending|succeeded|failed) |
POST webhooks/{id}/deliveries/{delivery}/retry |
Redelivery, same event id |
Add LiteApi::webhookDocs() to the version docs.annotations to document these routes.
REST resources with CoreController
One controller per resource serves every client (integrators, mobile app, web app). Everything below is opt-in:
expose() and CoreController behave as before by default.
class AgentController extends CoreController { // Strict creation for REST resources (firstOrCreate would return an existing record on POST) protected string $createStrategy = self::CREATE_STRATEGY_CREATE_ONLY; // Every action starts from this query: scope it to the actor protected function baseQuery(): Builder { return Agent::query()->whereBelongsTo(LiteApi::actor()->owner); } // Public ids instead of the primary key protected function findRecord(Builder $query, mixed $id): ?Model { return $query->whereKey(Agent::decodeHashid($id))->first(); } // Stable representation of the version (also usable for webhooks) protected function present(Model $model): mixed { return AgentPresenter::present($model); } // Business actions; throw LiteResponseException to refuse with a message public function create(array $data): Model { return CreateAgent::run(LiteApi::actor()->owner, $data); } protected function persistUpdate(Model $model, array $data): Model { return UpdateAgent::run($model, $data); } protected function persistDelete(Model $model): void { DeleteAgent::run($model); } } // GET /agents, GET /agents/{id}, POST /agents, PUT|PATCH /agents/{id}, DELETE /agents/{id}, PATCH /agents/{id}/restore AgentController::expose('agents', softDelete: true, rest: true);
Other hooks: dataForAdd() / dataForUpdate() (input kept, fillable by default), onBeforeRestore().
Pagination: $pagination per page (50), overridden by ?perPage=20 up to $maxPagination (100).
Invalid values (0, negative, text) fall back to $pagination.
Actor and permissions
The actor is who calls the API: the owner whose data is reached, the user acting if any, and their permissions. Default: the authenticated token owner, permissions = token abilities. Resolve it your way once:
LiteApi::resolveActorUsing(function (Request $request): ?Actor { // company API key, or user token + X-Company header... return new Actor(owner: $company, user: $user, can: fn (string $permission) => ...); }); Route::middleware(RequirePermission::class . ':permissions.manage_integrations')->group(...);
Webhook endpoints use the actor owner by default.
Hosted sessions
A signed, expiring link to a page of your application that an API client gives to its own user: connect a channel through a provider's JavaScript flow, fill a form you already built... The client never embeds your UI code; it creates a session, redirects its user, and follows the outcome.
php artisan vendor:publish --tag=lite-api-hosted-sessions && php artisan migrate
1. Declare the purposes and the page
// config/lite-api.php 'hosted_sessions' => [ 'route' => 'connect.page', // named route of your page, with a {token} parameter 'purposes' => ['whatsapp' => 'Connect a WhatsApp number'], ], // routes/web.php Route::get('connect/{token}', ConnectPage::class) ->middleware('lite-api.hosted-session:whatsapp') // resolves the token, 404 otherwise ->name('connect.page');
In the page, ResolveHostedSession::session($request) is the session: isUsable() (neither finished nor expired),
markOpened(), then complete($result) or fail($error), and returnUrl() to send the user back to the client
(?session_id=...&status=...). The token is only stored hashed and only given once, in the creation response.
The page responses are sent with Cache-Control: no-store and Referrer-Policy: no-referrer.
2. API
// inside the authenticated (versioned) group LiteApi::hostedSessionRoutes(); // GET purposes, GET / POST, GET / DELETE {id} // optional: validate the context of a purpose (return what to store, throw a LiteResponseException to refuse) HostedSessionController::contextUsing('whatsapp', fn(Request $request, Model $owner) => [...]);
Add LiteApi::hostedSessionDocs() to the version documentation annotations. Listen to HostedSessionCompleted
and HostedSessionFailed to notify the client (a webhook event for instance).