Mirror notice: if you're reading this on
gaitco/dartvel-laravel, you're looking at an automatically generated, read-only split of thelaravel/package from thegaitco/dartvelmonorepo — published so Packagist can find acomposer.jsonat the repo root. Please file issues and PRs upstream, at github.com/gaitco/dartvel, not here.
A contract-enforced API layer for Laravel apps that talk to the
dartvel Flutter
client: a response envelope, an error_code
taxonomy on top of Laravel's own exceptions, pagination meta, locale
negotiation, and a ready-made device-registration endpoint for push
notifications.
composer require gaitco/dartvelRendersContract::register() maps Laravel's built-in exceptions to the
contract's error envelope ({"message": ..., "error_code": ..., "errors": ...}) for any request whose path matches config('dartvel.exceptions.paths')
(default ['api/*']) and expects JSON.
This happens automatically as soon as the package is installed — the
service provider hooks the app's real exception handler
(callAfterResolving(ExceptionHandler::class, ...)), so api/* requests get
contract-shaped JSON errors with zero configuration. You do not need to
call anything in bootstrap/app.php for this to work.
Scope or disable it via config:
// config/dartvel.php
'exceptions' => [
'enabled' => true, // set false to disable the renderer entirely
'paths' => ['api/*'], // only requests matching these path patterns are rendered
],This matters if your app has non-Dartvel JSON endpoints (a legacy API, a
webhook receiver, an Inertia XHR route) that expect Laravel's own exception
format — narrow paths to just the routes your Dartvel client talks to, or
set enabled to false and call RendersContract::register() yourself
from bootstrap/app.php with your own gating.
Call RendersContract::register($exceptions) yourself only if you want to
add your own renderers that should run first:
// bootstrap/app.php
use Gaitco\Dartvel\Exceptions\RendersContract;
->withExceptions(function (Exceptions $exceptions) {
RendersContract::register($exceptions);
// your own renderers, if any — see precedence note below
})Precedence: Laravel resolves render() callbacks first-registered,
first-non-null-wins. Your app's own withExceptions() renderers are
registered before the package's auto-wired ones (which run via
callAfterResolving, after the app boots), so an app-defined renderer for
the same exception type always wins over the package's default. You only
need RendersContract::register() in bootstrap/app.php if you want the
package's mappings to run before some other renderer you're adding for a
different exception type.
| Exception | error_code |
HTTP status |
|---|---|---|
Illuminate\Validation\ValidationException |
validation_failed |
422 |
Illuminate\Auth\AuthenticationException |
unauthenticated |
401 |
Illuminate\Auth\Access\AuthorizationException |
forbidden |
403 |
Symfony\...\AccessDeniedHttpException |
forbidden |
403 |
Illuminate\Database\Eloquent\ModelNotFoundException |
not_found |
404 |
Symfony\...\NotFoundHttpException |
not_found |
404 |
Symfony\...\TooManyRequestsHttpException |
rate_limited |
429 |
any other Throwable |
server_error |
500 |
The 500 case's message is the real exception message when app.debug is
true, and the literal "Server error." otherwise.
Known limitation (v0.1): these six messages (
"The given data was invalid.","Unauthenticated.","This action is unauthorized.","Resource not found.","Too many requests.","Server error.") are English string literals — they are not translated, even for a request whose locale was set bydartvel.locale. Validation field messages ($e->errors(), e.g."The email field is required.") still go through Laravel's own translator as usual; it is only these six top-level messages that are hardcoded.
Two aliases are registered by the service provider:
dartvel.contract— stamps every response with theX-Contract-Versionheader (fromconfig('dartvel.contract_version')), so the Dart client can detect a wire-incompatible backend.dartvel.locale— setsapp()->setLocale()from (in order) theX-localizationheader, theAccept-Languageheader — each checked againstconfig('dartvel.locales')and used only if it names a listed locale — falling back unconditionally toconfig('app.locale')if neither header matches.
Append both to your api middleware group in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('api', ['dartvel.locale', 'dartvel.contract']);
})use Gaitco\Dartvel\Http\ApiResponse;
// {"data": ..., "message": ...}
return ApiResponse::data($post, 'Created successfully.', 201);
// {"data": [...], "message": null, "meta": {"current_page": ..., ...}}
return ApiResponse::paginated(Post::query()->paginate());
// {"message": ..., "error_code": ..., "errors": {...}}
return ApiResponse::error('The given data was invalid.', 'validation_failed', 422, $errors);ApiResponse::data() resolves an Illuminate\Http\Resources\Json\JsonResource
passed as $data into the data key (not merged as a sibling).
ApiResponse::paginated() accepts either a LengthAwarePaginator or an
AnonymousResourceCollection (e.g. PostResource::collection($paginator)).
Publishing is not required — the migration and POST api/devices route load
automatically. The migration creates a devices table (unique_id unique,
fcm_token, type, locale, timezone, notifications_settings JSON,
nullable polymorphic owner). The route is configured via config('dartvel.devices'):
// config/dartvel.php
'devices' => [
'route_prefix' => 'api/devices',
'middleware' => ['api', 'dartvel.contract'],
],route_prefix is registered bare — it does not inherit an /api prefix
from Laravel's routing (the 'middleware' => [...] entry above applies
middleware, not a URL prefix). The default 'api/devices' matches the Dart
quickstart's baseUrl (https://api.example.com/api) plus the Dart client's
default DeviceRegistrar path: '/devices' — see
Dart README's Quickstart.
If your baseUrl
does not already end in /api, adjust route_prefix (and/or the Dart
side's path) so the two agree on a URL, e.g.:
'devices' => [
'route_prefix' => 'devices',
'middleware' => ['api', 'dartvel.contract'],
],The default middleware also includes dartvel.contract, so the package's
own endpoint stamps X-Contract-Version even before you wire that
middleware into your app's own api group (see
Middleware below).
If the request is authenticated, DeviceController attaches the current
user as the device's polymorphic owner. Give your User model the inverse
relation with HasDevices:
use Gaitco\Dartvel\Devices\HasDevices;
class User extends Authenticatable
{
use HasDevices; // adds $user->devices(): MorphMany
}Requires laravel/sanctum — install and
configure it first. Then publish the auth scaffold:
php artisan vendor:publish --tag=dartvel-authThis writes three files you own and can edit freely:
app/Http/Controllers/Auth/AuthController.php—login(issues a Sanctum token viacreateToken()),me,logoutapp/Http/Requests/Auth/LoginRequest.phproutes/dartvel-auth.php—POST auth/login, andauth:sanctum-gatedGET auth/me/POST auth/logout
Remember to require __DIR__.'/../routes/dartvel-auth.php'; from your
routes/api.php (or register it in bootstrap/app.php) — publishing does
not wire the route file in for you. OTP endpoints (otp/request,
otp/verify) are not generated — the stub has a TODO(app) marker
where to add them if your app uses OTP auth.
php artisan vendor:publish --tag=dartvel-config// config/dartvel.php
return [
'contract_version' => '1', // stamped as X-Contract-Version by dartvel.contract
'locales' => ['en'], // locales dartvel.locale will accept
'exceptions' => [
'enabled' => true, // set false to disable RendersContract entirely
'paths' => ['api/*'], // only these path patterns get contract-shaped errors
],
'devices' => [
'route_prefix' => 'api/devices',
'middleware' => ['api', 'dartvel.contract'],
],
];| Envelope key | Present on | Meaning |
|---|---|---|
data |
every success response | the payload — object, array, or null |
message |
every response | human-readable message, or null |
meta |
paginated success responses | current_page, last_page, per_page, total, has_more |
error_code |
every error response | machine-readable taxonomy — see table above |
errors |
validation_failed only |
{"field": ["message", ...]} |
X-Contract-Version's major segment (config('dartvel.contract_version'),
default "1") must match the Dart client's compiled-in major version — a
mismatch makes every client call throw ContractViolationException instead
of misparsing an incompatible wire format. See the golden fixtures in
contract/ at the
repo root — both packages' test suites read the same files, so drift
between them fails CI. Contract-versioning policy:
root README.

