In the previous chapter the driver's contract was described as transport-level. This is what that means in practice:
$content = (string) $data->json('choices.0.message.content');
$result = json_decode($content, true);
if (! is_array($result) || $result === []) {
throw new \RuntimeException(
'AI site content response could not be decoded into a non-empty array.'
);
}
/** @var array<string, mixed> $result */
return $result;
The driver promises exactly one thing: what comes back from here is a decoded, non-empty array. Not that it is useful. Not that it is complete. Decodable and non-empty.
That is a small promise, and it is the right size, because the driver has no idea what the caller wanted. It does not know which category of content was requested, which keys the template needs, or what the user typed. Validating meaning here would mean teaching the driver about every feature that uses it, and the driver would then need editing every time a feature changed its mind.
Make the promise small, make it absolute, and put the judgement elsewhere.
Valid JSON That Ruins Your Afternoon
Here is the failure that taught us the difference, and it is worth walking through slowly because everything about it looked fine.
Structured-output mode was on. The response parsed. The array was non-empty. The driver returned it, the DTO accepted it, the resource serialised it, and the content service committed the result to the site's repository.
The model had omitted hero.title.
Nothing threw. The DTO had a nullable field, because of course it did — some sites genuinely have no subtitle, so nullability was reasonable somewhere in that chain. By the time the omission mattered it had been carried through four layers, each of which considered it somebody else's problem, and what got committed to a real repository was a content file whose hero section resolved to a literal return null;.
The site rendered. It rendered a blank hero.
Nobody was wrong in isolation. The provider honoured its contract: the JSON was valid. The driver honoured its contract: the array decoded and was non-empty. The DTO honoured its contract: the field was nullable. The gap was that no layer had ever been told what a complete answer looks like — and a chain of individually reasonable permissive decisions produces a system with no opinion at all.
One Schema, Read by Everyone
The fix was not another check. It was noticing that the content's structure was being described independently in three places, in three vocabularies:
- the prompt builder, which told the model what to produce;
- the output validation, which decided whether it had;
- the DTO and resource, which shaped it for consumers.
Three descriptions of one contract, edited at different times by different people. Naturally they drifted. A section added to the prompt did not appear in the resource. A key the resource exposed was never actually requested.
So the structure moved into one class that all of them read:
final class ContentSchema
{
public const BUSINESS = 'business';
public const EVENT = 'event';
/** Sections shared by every category, in output order. */
private const SHARED_TAIL = ['faqs', 'map', 'contact', 'brand', 'navigation', 'seo'];
private static function leadSections(string $category): array
{
return $category === self::EVENT
? ['hero', 'schedule', 'highlights', 'gallery']
: ['hero', 'services', 'testimonials'];
}
/** @return list<string> */
public static function sections(string $category): array
{
return [...self::leadSections(self::normalize($category)), ...self::SHARED_TAIL];
}
}
Two properties make this work.
It is framework-free. Pure constants and static methods, no container, no config, no service provider. That is what lets the same class live in a shared package and be read by every service that touches generated content — the one that generates it and the one that commits it — instead of each keeping a copy that slowly stops matching.
It carries a version. When sections or required paths change materially, the version is bumped, so a consumer can record which shape a given piece of content was generated against. Generated content outlives the prompt that produced it. Being able to answer "which contract was this written under?" six months later is worth the one method it costs.