outrightvision/api-model
Outrightvision's api-model adds an API-ready base model for Laravel, simplifying how Eloquent resources are exposed, queried, and transformed for JSON endpoints—handy for teams building consistent, filterable REST APIs without repeating boilerplate.
Know what you're actually installing. Despite “Generate Laravel-like Api Models” in the description, this package generates nothing: no artisan command, no controllers, no routes, no migrations, no config file, no service provider. It is a single abstract base class that takes a decoded JSON array and gives you Eloquent-flavoured, read-only property and relationship access. If the ticket reads “scaffold an API layer,” close the ticket — what you're buying is a nicer way to walk nested partner payloads.
Three steps to first value:
composer require outrightvision/api-model
There is nothing to publish or register. Next, create your own namespace — App\Api\Models is the convention — with one class per upstream resource:
namespace App\Api\Models;
use OutrightVision\ApiModel\ApiModel;
/**
* @property-read string $id
* @property-read string $name
* @property-read Company|null $company
*/
class User extends ApiModel
{
protected $cast_model = ['company' => Company::class];
public function company()
{
return $this->belongsTo(Company::class, 'company');
}
}
Then hydrate at the boundary:
$user = new App\Api\Models\User(Http::get($url)->throw()->json());
$user->company->geo->lat; // no $data['company']['geo']['lat'] ?? null
Read the base class source before your second model. get_data(), $cast_model, hasOne/belongsTo/hasMany, included_default, cast_dates, date_timezone, and $requiredParameters are the entire public surface — it fits in one sitting, and that reading replaces documentation that doesn't exist.
First use case: one low-traffic, deeply nested, read-only endpoint. Pick an integration where the payload is genuinely nested (data.company.geo.lat, posts.data[]) so the ergonomics are obvious. Prove $user->company->geo->lat works, then add a hasMany and a cast_dates field. Do not start with your most important customer-facing call — this is read-path convenience and you want to feel the shape before committing.
Add a fixture on day one. Check a real (redacted) response into tests/fixtures/ and assert every field your app reads. Unknown fields return null silently, so a fixture-based contract test is your only defence against upstream schema drift.
Hydrate at the boundary; keep the package out of your domain. Treat App\Api\Models\* as a read-only adapter, never as a domain entity. Put it behind an interface — ApiGateway, a per-integration repository, or a plain service that returns native arrays or your own DTOs:
interface CrmGateway { public function user(string $id): UserData; }
class OutrightCrmGateway implements CrmGateway
{
public function user(string $id): UserData
{
$payload = Http::retry(3, 100)
->withToken(config('services.crm.token'))
->get("/users/{$id}")
->throw()
->json();
$model = new App\Api\Models\User($payload);
return new UserData(
name: $model->name,
companyLat: $model->company?->geo?->lat,
);
}
}
This costs one extra class and buys you a one-file swap to spatie/laravel-data or Saloon if the dependency sours. Given four stars and zero dependents, that reversibility is the whole point.
Declare the mapping, don't generate it. Each model is 10–30 lines: $cast_model for nested objects, relationship methods for collections, cast_dates + date_timezone for temporal fields, $requiredParameters for “this must exist”. A reviewer sees the entire API contract on one screen — that reviewability is the strongest day-to-day argument for the package in a Laravel shop.
Prefer declared access over dotted paths. $cast_model and relationship methods are grep-able and refactor-safe; get_data('a->b.c') is not. Reserve get_data() for the odd path declared properties genuinely can't cover.
Lean on lazy relations, steer them with included_default. Relations only materialise on access, so you don't pay for a graph you never walk — a real win against fat vendor payloads. When you will traverse a relation on every item in a collection, set included_default so each model doesn't trigger its own walk:
$users = collect($payload['data'])
->map(fn ($p) => new App\Api\Models\User($p));
$users->each(fn ($u) => $u->company->name); // cheaper with included_default
Keep collections boring. A collect()->map() at the boundary is enough. Don't build helper plumbing around the package — that plumbing is precisely the layer you'd rewrite on a swap.
One flat namespace, one convention. App\Api\Models, one file per upstream resource, mirroring the API's naming rather than your domain's. Any Eloquent-literate engineer reads the codebase on day one, which is the practical onboarding argument.
Contract tests per model, not per integration. One fixture + one test per class, asserting every field the app touches plus every relation. When the vendor renames geo to location, the failing test names the exact models and fields instead of production silently returning null.
Keep transport, caching, and retries outside. The model knows nothing about the network and shouldn't learn. Retry, cache, then hydrate.
$user->company->geo->renamed_key returning null is by design, and it looks identical to a legitimately absent optional field. Compensate with $requiredParameters on required models plus fixture-based contract tests. There is no schema validation built in.$requiredParameters is thin. Treat it as “this model must exist”, not field-level validation. Real validation, error semantics, and retry policy are yours to write.@property-read docblocks to every model class from the very first one, or PHPStan and IDE completion vanish. Retrofitting them across a dozen models is tedious busywork.cast_dates against a real response. It routes through Carbon::parse(); empty strings, ambiguous formats, and timezone-less timestamps all behave unpredictably. Verify date_timezone defaults against an actual payload before trusting them.included_default actually helps for your shape rather than assuming it does.dump($payload) before constructing the model.spatie/laravel-data or native JsonResource remain the mature answers.packages/ or a Composer repositories entry the moment this touches anything that matters.laravel-data DTO. If the Eloquent ergonomics don't win clearly, you've lost two days instead of a quarter — and you've still got the gateway interface to show for it.How can I help you explore Laravel packages today?