enadstack / laravel-api-contracts
Shared versioned commerce contracts for event schemas, API conventions, money DTOs, reference formats, error envelopes, pagination, and integration standards.
Package info
github.com/Enadabuzaid/laravel-api-contracts
pkg:composer/enadstack/laravel-api-contracts
Requires
- php: ^8.3
- illuminate/console: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/pagination: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- laravel/pint: ^1.27
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-11 12:07:21 UTC
README
Shared versioned commerce contracts for event schemas, API conventions, money DTOs, reference formats, error envelopes, pagination, and integration standards — consumed by the independent Laravel microservices in this workspace.
Implementation Status
IN PROGRESS — Enadstack\ApiContracts\Http\Responses\ApiResponses (success/error/pagination response trait) and Enadstack\ApiContracts\Http\Exceptions\ApiExceptionRenderer (global exception normalization) are implemented and consumed by identity-access-service as the reference integration.
API Response Conventions
- Success with data stays flat — no
datawrapper — so each service's contracted field names (e.g.user,session) live at the top level:{"user": {...}, "session": {...}}. - Success, message only:
{"message": "..."}. - Error:
{"error": {"code": "SCREAMING_SNAKE_CASE", "message": "...", "details": {}}}—detailsis always a JSON object, never an array, even when empty. - Paginated success is the one place
datais used as a wrapper key, matching Laravel's own default paginated-resource shape:{"data": [...], "links": {...}, "meta": {...}}.
This means data is reserved specifically for array/paginated collections; single-object success responses stay flat. Don't "fix" this into wrapping everything in data.
Installation (local path repository)
In a consuming service's composer.json:
{
"repositories": [
{ "type": "path", "url": "../laravel-api-contracts" }
],
"require": {
"enadstack/laravel-api-contracts": "*"
}
}
Then:
composer require enadstack/laravel-api-contracts:*
php artisan vendor:publish --tag=api-contracts-config
In bootstrap/app.php, inside ->withExceptions():
use Enadstack\ApiContracts\Http\Exceptions\ApiExceptionRenderer; ->withExceptions(function (Exceptions $exceptions) { ApiExceptionRenderer::register($exceptions); })
And in the service's base app/Http/Controllers/Controller.php:
use Enadstack\ApiContracts\Http\Responses\ApiResponses; abstract class Controller { use ApiResponses; }
Idempotency, webhooks and events (SERVICE_STANDARD §6)
Idempotency middleware — publish the table, then add idempotency to write routes:
php artisan vendor:publish --tag=api-contracts-migrations && php artisan migrate
Route::middleware(['service.auth', 'idempotency'])->post('/v1/points/earn', ...);
A request with an Idempotency-Key header is stored per (caller, key) for 24 h
(api-contracts.idempotency.ttl). A replay returns the stored body with
Idempotent-Replayed: true; same key with a different body returns 422
IDEMPOTENCY_KEY_REUSED; a concurrent duplicate returns 409 IDEMPOTENCY_KEY_IN_PROGRESS.
5xx responses are not stored.
Webhooks
$headers = (new WebhookSigner)->headers($secret, $body); // X-Webhook-Id/Timestamp/Signature (new WebhookVerifier)->verify($secret, $body, $request->headers->all()); // throws InvalidWebhookException; rejects > 5 min
Event envelope
$event = EventEnvelope::make('loyalty.points.earned', 'loyalty-service', 'tafadel', 'tafadel:tenant:12', ['points' => 50]); json_encode($event); // {id,type,version,producer,application,tenant_reference,occurred_at,data} EventEnvelope::fromArray($payload); // consumer side
Transactional outbox — events commit with your data, a scheduled command delivers them:
DB::transaction(function () use ($order) { $order->save(); app(RecordOutboxEvent::class)->handle(EventEnvelope::make('order.order.placed', 'order-service', $app, $tenant, [...])); });
// config/api-contracts.php → outbox.subscriptions ['url' => 'https://app.test/hooks', 'secret' => '…', 'events' => ['order.*']]
Publish the migration (api-contracts-migrations) and schedule outbox:process
every minute. Each delivery is a signed webhook (X-Webhook-Id = event id). Failures
retry with backoff base * 2^(attempt-1) (backoff_base 30 s, backoff_max 3600 s) until
max_attempts (8), then the row is failed. Delivery is at-least-once; receivers dedupe on X-Webhook-Id.
Service auth, scopes and tenant context (SERVICE_STANDARD §4-5)
Route::middleware(['service.auth', 'service.scope:loyalty:earn', 'tenant.context', 'idempotency']) ->post('/api/v1/points/earn', ...); $principal = ServicePrincipal::from($request); // application, scopes, owner, tenantReference
service.auth—Authorization: Bearer <key>+X-Application; validated via identityGET {identity.base_url}/api/v1/api-key/introspect, cached per key hash. Errors: 401UNAUTHENTICATED, 403APPLICATION_MISMATCH, 503SERVICE_AUTH_UNAVAILABLE(fail closed).service.scope:a:b,c:d— all listed scopes required (*andns:*supported); 403INSUFFICIENT_SCOPE.tenant.context— 400TENANT_CONTEXT_REQUIRED/TENANT_CONTEXT_INVALID, 403TENANT_APPLICATION_MISMATCH/TENANT_FORBIDDEN.- Models:
use HasTenantReference;(columnstenant_reference,application). Queries/creates throwMissingTenantContextExceptionwithout a context. Jobs/tests:TenantContext::within($ref, $app, fn () => ...).
Config (api-contracts.php): identity.base_url (env IDENTITY_BASE_URL), identity.path,
identity.timeout (3), identity.cache_ttl (60), identity.cache_store, tenant.header, tenant.pattern.
Full details: docs/SERVICE_AUTH.md.