Chapter 2 ended with a controller that calls Licenses::all() and Licenses::create() and knows nothing else. This chapter builds what is behind those two calls.
In most Laravel applications the answer would be short: an Eloquent model. The list would be a query, casts would give you types, and you would be done. Use that when you can. A model, its casts, and a Resource are the convention, and nothing in this chapter improves on them.
The License API can’t. Its licenses live in Statamic, behind an HTTP API. So it needs three things a model would have given it for free: a typed object to hold a license, one class that knows how to talk to the provider, and a name the rest of the application can call without knowing which provider is behind it.
The first two live in one folder, app/Services/License. The third is a facade, in app/Facades. Those are the only two folders in this API that no Artisan command creates.
A Typed Object for Provider Data
An HTTP response is an array of whatever the provider decided to send. Pass that array around and every layer has to trust keys it can’t see. A typo in a key name survives until production, and your editor can’t autocomplete what it doesn’t know.
So the first thing that happens to a provider response is that it becomes an object:
// app/Services/License/License.php
readonly class License
{
/**
* @param array<int, string> $domains
*/
public function __construct(
public string $key,
public string $name,
public array $domains,
public string $createdAt,
) {}
}
It holds data and has no side effects. Its one method, below, builds it from the provider’s array. readonly means that once it exists it doesn’t change: it is a snapshot of what the provider said.
createdAt stays a string. It is tempting to parse it into a date here, because you are already touching the value. But this object’s job is to say what the provider sent, not to interpret it. Parse where you need a date, and the object stays a faithful record of the response.
You may know this pattern as a data transfer object. I avoid the name in the code for one reason: it invites a layer. A project that has DTO classes soon has one for every request and every response. This API has exactly one, standing where a model would stand if it could.
Where the Array Becomes an Object
The class also owns the conversion, and it does it with a tool you already know. A provider’s payload is input, and Laravel has a validator for input:
// app/Services/License/License.php
public static function fromProvider(array $data): self
{
$validator = Validator::make($data, [
'key' => ['required', 'string'],
'name' => ['required', 'string'],
'domains' => ['nullable', 'array'],
'domains.*' => ['string'],
'created_at' => ['required', 'string'],
]);
if ($validator->fails()) {
throw new MalformedProviderResponse(
$validator->errors()->keys(),
);
}
return new self(
key: $data['key'],
name: $data['name'],
domains: $data['domains'] ?? [],
createdAt: $data['created_at'],
);
}
This is where the boundary earns its keep. A missing domains, or a null one, is fine and becomes an empty array, because a license with no domains is valid. A missing key is not fine, and the method says so loudly, at the edge, rather than letting a null travel three layers inward and fail somewhere that can’t explain itself.
Two details matter here.
The exception is the application’s own, not the validator’s. If a ValidationException escaped from here, Laravel would render it as a 422, and the consumer would be told that their request was invalid when the fault is the provider’s. Chapter 7 gives MalformedProviderResponse the status it deserves.
And it carries the names of the fields that failed, not their values. A provider’s response is somebody else’s data, and it doesn’t belong in your logs.
It needs no network and no database, which makes it the cheapest thing in the project to test:
// tests/Unit/LicenseTest.php
it('accepts a license with no domains', function () {
$license = License::fromProvider([
'key' => 'lic_test',
'name' => 'Test License',
'created_at' => '2026-02-18T10:00:00Z',
]);
expect($license->domains)->toBe([]);
});
it('rejects a license without a key', function () {
License::fromProvider(['name' => 'Test License']);
})->throws(MalformedProviderResponse::class);
The validator is a facade, so even this test needs the application booted. Chapter 12’s tests/Pest.php does that for tests/Unit as well as tests/Feature.
When the provider changes its response format, and it will, this method is the only thing you update.
Model, Typed Object, Resource
People confuse these three, so here is the difference in one place.
A model talks to your database. A typed object like License carries data that came from somewhere that isn’t your database. A Resource decides what the outside world sees.
The typed object is your internal contract: everything we know about a license. The Resource is your external contract: what we are willing to show. If you have a model, you don’t need a typed object in front of it. If you don’t have a model, the typed object is what stands in its place.