In November 2023, Jason Liu gave a talk called "Pydantic is all you need." The argument was small and sharp: to get reliable structured data out of an LLM, you do not need a new framework or a query language. If you already know your language's schema library, you already have the tool. In Python that library is Pydantic.
In PHP the same argument holds. Your classes and your validators are all you need. InstructorPHP is the thin layer that connects them to the model.
This article ports Jason's point to PHP and shows what it looks like in real code.
The problem
An LLM returns text. Your application needs typed data it can store, route, and bill against.
The gap between those two is where most AI features rot. A model says "probably high priority," but the database column accepts low, medium, high, or critical. It writes "sometime this morning," but the workflow needs a timestamp. The moment you treat model text as if it were already valid data, you push that risk into every controller, job, and queue handler that touches the response.
The model can propose data. The application decides whether that data is usable.
A concrete example
Say you extract a contact from a signup message:
you can reply to me via jason wp.pl -- Jason
You want an object, not a string:
use Symfony\Component\Validator\Constraints as Assert;
class Contact
{
public string $name;
#[Assert\Email]
#[Assert\NotBlank]
public string $email;
}
The class is the schema. Field names, types, and the docblock become the instructions the model sees. There is no separate JSON schema to hand-write and keep in sync.
What failure looks like
A plausible response can still be unusable:
{
"name": "Jason",
"email": "jason wp.pl"
}
A person reads that as an email. Your application cannot. It has a space where an @ should be, so any code that sends mail, dedupes accounts, or keys a record on email will break later, far from the LLM call that caused it.
This is the point where hand-rolled parsing spreads through a codebase: one str_replace here, one regex there, each copy slightly different. That code is boundary logic. It belongs at the boundary, not scattered across features.
The solution
InstructorPHP asks the model for a response class and validates the result before your code sees it:
use Cognesy\Instructor\StructuredOutput;
$contact = StructuredOutput::using('openai')
->with(
messages: 'you can reply to me via jason wp.pl -- Jason',
responseModel: Contact::class,
)
->get();
If the email fails the Assert\Email constraint, the call does not return a broken Contact. It raises a validation error. Your application never accepts a value that its own rules reject.
Let the model fix its own mistakes
Rejecting bad output is half the value. The other half is repair.
Turn on retries, and InstructorPHP feeds the validation error back to the model and asks again. The next prompt is not a vague "try again." It names the field and the rule that failed.
use Cognesy\Instructor\StructuredOutput;
use Cognesy\Instructor\StructuredOutputRuntime;
use Cognesy\Polyglot\Inference\LLMProvider;
$runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai'))
->withMaxRetries(3);
$contact = (new StructuredOutput($runtime))
->with(
messages: 'you can reply to me via jason wp.pl -- Jason',
responseModel: Contact::class,
)
->get();
// $contact->email === "jason@wp.pl"
The first attempt produces jason wp.pl. Validation fails. The model receives the error, corrects the address, and the second attempt passes. Your code gets a typed object; it never sees the retry loop.
Reasoning is a field, not a prompt trick
Jason's talk made another point worth keeping: if you want the model to think before it answers, put the thinking in the schema. Add a reasoning field before the answer field, and the model fills it in as part of the structured output.
class Answer
{
/** Work through the problem step by step before answering. */
public string $reasoning;
public int $result;
}
Order matters. The model writes reasoning first, so the value in result follows from it. You get the chain of thought as data you can log, not as prose you have to parse out of a wall of text.
Why this matters for the business
The point is not that the model becomes perfect. It does not. The point is that imperfect output now crosses a boundary that is visible, testable, and recoverable.
| Scenario | Without a schema boundary | With InstructorPHP |
|---|---|---|
| Support routing | "urgent" quietly misroutes a ticket | invalid severity is rejected or repaired before routing |
| CRM enrichment | a malformed email pollutes account data | the record fails validation and never lands |
| Claims intake | a vague date reaches downstream systems | the field must parse or the claim does not open |
A failed extraction stops being a mystery string in a database row. It becomes a validation failure with a field name, a message, and a retry count you can log and audit.
Your application code gets simpler too. The service that consumes the result works with normal PHP types:
$accountService->register($contact);
No JSON inspection. No guessing at missing fields. The messy part stays at the edge.
The operating rule
Use a schema boundary whenever an LLM result crosses into something that matters: persistence, routing, a customer-visible action, billing, or compliance.
A working setup has four parts:
- Define the response class in PHP.
- Add validation constraints for the fields the business depends on.
- Allow a few retries so the model can correct itself with specific feedback.
- Log accepted objects and failed attempts with enough context to debug.
That is the whole idea. You do not have to invent your own parsing and repair glue for every feature. You reuse the classes and validators you already write, and let InstructorPHP hold the model to them.
Do that, and model output stops being a liability you defend against and becomes data your application can trust.