Jane 8: modern PHP models, Symfony HttpClient and lazy API calls
Jane 7.14 shipped on September 1st. Exactly one month later, we tagged 8.0.0: a major version, with breaking changes we chose one by one.
A major version of a code generator is a strange beast: it changes the code it writes, and therefore the code you write. We did not take that lightly. Jane generated PSR-18 clients with HTTPlug plugins and getter/setter models for years, and they worked. They also stacked three abstraction layers over an HTTP transport, hid the real shape of your data behind accessors, and made some features impossible to build at all. Jane 8 removes those layers. Jane 8.1, released on October 23rd, uses the room we made to add the most requested feature in the issue tracker: pagination.
This article walks through what changed in 8.0 and 8.1, why we changed it, and how to upgrade without losing an afternoon.
Section intitulée generated-models-are-plain-php-objects-nowGenerated models are plain PHP objects now
Let’s start with the change you will see first, in every file that touches a generated model. Jane 7 generated protected properties with a getXxx() / setXxx() pair each. Jane 8 generates public, natively typed properties:
// Jane 7
$pet = $client->getPet('pet-1');
echo $pet->getName();
$pet->setTag('good boy');
// Jane 8
$pet = $client->getPet('pet-1');
echo $pet->name;
$pet->tag = 'good boy';
Public typed properties are the way modern PHP recommends to model plain data. Since typed properties, a getter and a setter that only read and write a field add nothing but lines, and property hooks in PHP 8.4 removed the last reason to write them “just in case”. The generated classes are shorter, and your IDE and static analysis read the types straight from the declarations.
Section intitulée jane-now-runs-on-symfony-httpclientJane now runs on Symfony HttpClient
This is the heart of 8.0: the OpenAPI generated clients now run on Symfony HttpClient, and PSR-7, PSR-18 and HTTPlug are gone from the dependencies.
Why leave the PSR standards? We like them, but in Jane’s case they cost more than they gave. A generated request went through PSR-7 message objects, PSR-17 factories, an HTTPlug PluginClient, then a PSR-18 client, which we were using with Symfony HttpClient anyway. Generated code had to juggle PSR-7 streams for every body. Worst of all, the blocking request/parse cycle of PSR-18 left no room for deferred or concurrent requests. Symfony HttpClient gives us all of this natively, and it is the HTTP client we already use in our Symfony projects.
Creating a client looks almost the same:
use Symfony\Component\HttpClient\HttpClient;
// with no argument, Jane calls HttpClient::create() for you
$client = Vendor\Api\Client::create();
// or bring your own client: proxy, timeout, scoped client...
$client = Vendor\Api\Client::create(HttpClient::create(['timeout' => 5]));
The HTTPlug plugins are gone too. The second argument of create() now takes decorator factories, any callable(HttpClientInterface): HttpClientInterface. A closure is enough for most needs:
use Symfony\Contracts\HttpClient\HttpClientInterface;
$client = Vendor\Api\Client::create(null, [
static fn (HttpClientInterface $httpClient): HttpClientInterface => $httpClient->withOptions([
'headers' => ['Accept-Language' => 'en-GB'],
]),
]);
Authentication follows the same model: the generated authentication classes add their header or query parameter to the request options instead of rebuilding a PSR-7 request. The server URL from your specification is still applied automatically, through a small dedicated decorator.
Section intitulée your-api-calls-are-lazy-by-defaultYour API calls are lazy by default
Leaving PSR-18 unlocked the feature we had wanted for the longest time: choosing when a request is sent. Jane 8 adds an x-fetch-mode OpenAPI extension on GET and HEAD operations, with three values:
lazy(the default) sends nothing when you call the method, and the request goes out on first access to the result;eagerkeeps the historical behavior: send, wait, parse, return;preloadsends the request as soon as you call the method, without waiting for the response, so several preloaded calls run concurrently.
paths:
/pets/{id}:
get:
operationId: getPet
x-fetch-mode: preload
You can also change the default for every GET and HEAD operation with the default-fetch-mode generation option. Other verbs (POST, PUT, PATCH, DELETE…) always stay eager: a lazy POST that never runs because nobody read its result would be a nasty surprise.
So what does a lazy call return, if the response does not exist yet? It returns a real instance of the generated model class, built as a lazy ghost with the native lazy objects of PHP 8.4. The object passes instanceof, can be cloned and serialized, and behaves like any model. The first property read triggers the request, runs the endpoint’s own status code mapping, and fills the object:
$pet = $client->getPet('pet-1'); // nothing has been sent yet
if ($showDetails) {
echo $pet->name; // the request is sent and parsed here
}
// if $showDetails is false, no request is ever sent
The same mechanism makes preload useful for pages that aggregate several API calls. The idea is not new on this blog: preload comes straight from our 2024 article PHP Object Lazy-Loading is More Than What You Think, which combined lazy-loading with Symfony HttpClient to start hundreds of API calls early and read their responses only when needed. Jane 8 turns that idea into generated code:
// x-fetch-mode: preload on getPet and getOwner
$pet = $client->getPet('pet-1'); // the request starts here
$owner = $client->getOwner('owner-42'); // and this one too, in parallel
echo $pet->name; // waits for the pet response if needed, then parses it
echo $owner->name; // most likely already received
Lazy calls come with a few trade-offs: exceptions are thrown on first access rather than at the call site, ghosts need PHP 8.4 (older versions fall back to eager), and only responses that map to a single model can be deferred. The fetch modes documentation covers them in detail. If you prefer the 7.x behavior, 'default-fetch-mode' => 'eager' restores it in one line.
Section intitulée jane-is-stricter-starting-before-it-writes-a-single-fileJane is stricter, starting before it writes a single file
Section intitulée your-schema-is-validated-before-anything-else-happensYour schema is validated before anything else happens
A code generator fails in the worst possible way when it crashes halfway: a raw TypeError deep inside a denormalizer, a pointer to generated code you never wrote, and no clue about the line of your specification that caused it. During the 7.x cycle, we added a validation pass that runs before any guessing or generation. It checks the document against the features the selected component supports, collects every problem in a single run, and reports each one with its exact JSON pointer and a fix hint.
Jane 8 makes this pass the entry gate of every new feature. Put x-fetch-mode on a POST, for example, and you get this before a single file is written:
Unsupported feature(s) found in your schema:
`x-fetch-mode` is only supported on GET and HEAD operations but was found on a post operation at "/paths/~1pets/post/x-fetch-mode". Other verbs are always executed eagerly: remove the attribute.
The same philosophy spreads to the configuration. The --config-file value is validated before reaching the loaders, and the Symfony bundles now validate jane_json_schema: / jane_open_api:: an unknown key fails loudly instead of being silently ignored. A typo in an option name used to cost you a debugging session. Now it costs you one error message.
Section intitulée undeclared-status-codes-throwUndeclared status codes throw
throw-unexpected-status-code is now enabled by default. When an API answers with a status code (or a content type) that the specification does not declare, the generated endpoint throws a BadResponseException carrying the response, instead of silently returning null. We announced this change back in #377, and users asked for it again in #815. A null that should have been a Pet tends to explode three function calls later, far from its cause. An exception at the boundary is easier to debug. If you need the old behavior, set the option to false.
Section intitulée you-can-now-customize-the-generationYou can now customize the generation
Until now, customizing what Jane generates meant forking a generator class. Jane 8 adds an experimental event system built on symfony/event-dispatcher. You attach subscribers to a dispatcher and pass it to Jane::build() or JaneOpenApi::build().
The events come in two families. Lifecycle events (GenerationStartedEvent, SchemaStartedEvent, FileGeneratedEvent…) carry read-only payloads. Mutation events carry live references that you can change: PropertyGuessedEvent lets you replace a guessed type (models and normalizers follow), while PropertyGeneratedEvent and ClassGeneratedEvent hand you the PhpParser AST of the model, to add docblock tags, traits, interfaces or methods.
Here is a subscriber that makes every generated model implement one of your interfaces:
use Jane\Component\JsonSchema\Event\ClassGeneratedEvent;
use Jane\Component\OpenApi3\JaneOpenApi;
use PhpParser\Node\Name\FullyQualified;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new class implements EventSubscriberInterface {
public static function getSubscribedEvents(): array
{
return [ClassGeneratedEvent::class => 'onClassGenerated'];
}
public function onClassGenerated(ClassGeneratedEvent $event): void
{
$event->getClassNode()->implements[] = new FullyQualified(App\Api\ApiModel::class);
}
});
JaneOpenApi::build($options, dispatcher)->generate(registry);
We marked the API experimental on purpose. The constructors of the shipped events are frozen, but the list of events may grow, and you cannot register listeners from the .jane-openapi configuration file yet.
The lifecycle events already power something everyone gets for free: the generate command finally tells you what it is doing. You now see a header per schema, the timings of the guessing and generating phases, the number of files written by kind (models, normalizers, endpoints…) and the total duration. --quiet keeps it silent.
Section intitulée how-do-you-make-sure-a-generator-writes-correct-codeHow do you make sure a generator writes correct code?
Jane’s test suite compares generated code against committed fixtures. That proves the output did not change by accident. It does not prove the output is correct, and it says nothing about the specifications that real companies publish.
We added two safety nets during the 8.0 cycle. The first one runs Mago on the generated code itself. We measured both candidates on the 183 fixtures of the suite (about 20,200 files): PHPStan needed 638 seconds and five processes to avoid running out of memory, while Mago went through everything in 2 seconds. Mago surfaced, among other things, more than 2,000 missing return statements in generated code, and 8.0 fixed them.
The second net is a nightly corpus job. It generates a client for twelve real-world specifications published by their vendors (Kubernetes, Docker, Slack, Netlify, GitHub in 3.0 and 3.1, Stripe, Plaid, Box, Twilio, Discord and OpenAI) and checks that generation does not crash and that the output parses. It paid for itself before the release: it found a fatal error on the public Slack Swagger 2.0 specification and another one on the OpenAI 3.1 specification, both fixed in 8.0.0. Since 8.1, the job also requires zero Mago findings on the whole corpus.
Section intitulée jane-8–1-pagination-built-inJane 8.1: pagination built in
Paginating an API by hand is the kind of code everyone writes, nobody tests, and everyone gets slightly wrong. The request for native pagination support (#324) had been open since June 2020. Jane 8.1 closes it.
The new pagination option targets GET operations by path regex, with the same semantics as whitelisted-paths. For each matching operation, Jane generates a sibling <operation>Paginated() method. It takes the same arguments as the original one and returns a \Generator that yields the items of every page:
$client = Vendor\Api\Client::create();
foreach ($client->listItemsPaginated(['limit' => 50]) as $item) {
// $item is a Vendor\Api\Model\Item, whatever page it comes from
}
The generator is lazy in the good sense: it requests a page only once you have consumed the previous one, so a break in your loop stops the requests. Real APIs do not agree on how to paginate, so we shipped four strategies to cover the common cases:
return [
'openapi-file' => __DIR__ . '/openapi.yaml',
'namespace' => 'Vendor\Api',
'directory' => __DIR__ . '/generated',
'pagination' => [
// ?cursor=... read from "meta.nextCursor" in the body, stops when it is null or empty
['^/items$', 'GET', ['strategy' => 'cursor', 'param' => 'cursor', 'next-cursor' => 'meta.nextCursor', 'items' => 'data']],
// ?page=... incremented from its default in the specification, stops on the first empty page
['^/repos$', 'GET', ['strategy' => 'page', 'param' => 'page']],
// follows the RFC 8288 `Link: <...>; rel="next"` header, the GitHub way
['^/repos/\{owner\}/issues$', 'GET', ['strategy' => 'link-header']],
// follows a next page URL found in the body, such as OData's @odata.nextLink
['^/users$', 'GET', ['strategy' => 'next-link', 'next' => '@odata.nextLink', 'items' => 'value']],
],
];
You probably guessed it: this configuration goes through the same validation as your schema, so a typo or a path that does not exist on the response model stops the generation with a clear message. The pagination documentation covers the remaining options, such as yielding whole pages or capping the number of requests.
8.1 also brings smaller improvements around enums and several fixes, all listed in the changelog.
Section intitulée upgrading-without-losing-an-afternoonUpgrading without losing an afternoon
Jane 8 requires PHP 8.3 (7.x accepted 8.1). The upgrade guide lives in the compatibility documentation, but we went one step further. Jane now ships agent skills, usable by any coding agent that supports skills. One of them, jane-upgrade, knows the full 7.x to 8.x path:
npx skills add janephp/janephp --skill jane-upgrade
The skill bumps every jane-php/* package together, regenerates your code, rewrites the getter and setter calls, migrates HTTPlug plugins to decorators, and checks the result with a final grep for leftovers. Two sibling skills, jane-openapi-sdk and jane-json-schema-models, help you start a new client or a new set of models.
Whether you upgrade by hand or with an agent, our advice stays the same: upgrade in two steps. First, add these two lines to your OpenAPI configuration to keep the 7.x runtime behavior:
'default-fetch-mode' => 'eager',
'throw-unexpected-status-code' => false,
Then bump the packages, regenerate, and fix the calling code until your test suite passes. Once everything is green, remove the two lines one at a time and enjoy lazy calls and strict status codes.
Section intitulée conclusionConclusion
If you remember one thing from Jane 8: the generated clients now speak Symfony HttpClient natively, and your GET calls are lazy by default. The rest (strict status codes, real typed properties, validation before generation, native pagination) follows from that same decision to generate the code we would write ourselves.
Give it a try, run the jane-upgrade skill on a branch, and tell us what breaks. And if you need help with an API integration or a migration, contact us 😉
Commentaires et discussions
Ces clients ont profité de notre expertise
Canal+ a sollicité l’expertise technique de JoliCode dans le cadre d’un audit technique du framework PHP employé par l’entreprise pour ses développements. À l’aide de notre outillage projet, nous avons évalué la qualité du framework et son adéquation avec l’écosystème PHP actuel, et émis une série de recommandations en vue de la modernisation du socle…
Axiatel à fait appel à JoliCode afin d’évaluer les développements effectués par la société. A l’issue d’un audit court, nous avons présenté un bilan de la situation actuelle, une architecture applicative cible, et nos recommandations en vue d’une migration en douceur vers le modèle applicatif présenté.
Armadillo édite un moteur de base de données spécialisé dans la gestion de données multimédias et des métadonnées associées. Depuis de nombreuses années, cette plateforme est accessible par le biais d’un connecteur PDO pour PHP, dont nous avons facilité l’intégration en développant une librairie PSR-0 ainsi qu’un bundle Symfony. Notre mission a principalement…