DEV Community

Cover image for Too Big to Merge: How One Pull Request Became a New Symfony Bundle Generator
Jozef Môstka
Jozef Môstka

Posted on AI-assisted

Too Big to Merge: How One Pull Request Became a New Symfony Bundle Generator

Over the past two and a half years I've released about ten Symfony bundles. Some stayed experiments, others took on a life of their own. altcha-bundle (ALTCHA captcha for Symfony forms) has passed 50,000 downloads and gets around 11,000 a month. ux-sdc, Single Directory Components for Symfony UX, cost me the most sleepless nights. And progressive-image-bundle still makes me smile every time I watch an image load on a slow connection without the layout jumping around.

Every one of them started with the same command:

composer create-project yceruto/bundle-skeleton my-bundle
Enter fullscreen mode Exit fullscreen mode

The skeleton by Yonel Ceruto (a Symfony core team member) has saved me a lot of time. It asks a few questions, creates a directory structure that follows the best practices, and generates a bundle class built on AbstractBundle.

This post is about how I set out to give it a small refresh and ended up with a completely new project. It's also about the fact that I didn't write a single line of its code. AI agents wrote it. My job was what people have started calling orchestration: handing out work, making decisions, checking results and saying no. It's all in the commit history, where every commit carries Co-Authored-By: Claude. I'm not hiding that, because it's the most interesting part of the story.

When a skeleton stands still

The generator's last change dates back to November 2024. Since then, Symfony 7.4 LTS, 8.0 and 8.1 have shipped. XML service configuration was deprecated in 7.4 and is gone in 8.0. The skeleton still claimed to support ^6.4|^7.0.

With every new bundle, I added the same things by hand after generation:

  • phpunit.dist.xml and a test kernel, so the bundle could be tested at all,
  • GitHub Actions with a matrix of PHP and Symfony versions,
  • PHPStan, PHP-CS-Fixer and their configuration,
  • a LICENSE file, because the generator deleted the skeleton's copy and never created a new one.

When I asked an AI to read the best practices and compare them with what the generator actually did, it found things I had overlooked for years. The package acme/blog-bundle produced a class called BlogBundle, while the guidelines say it should be AcmeBlogBundle. The configuration alias was derived incorrectly for some names, and for a package name without a dash it came out empty.

How the work was organized

At first I didn't want code. I wanted a plan. An agent got the links to the Symfony bundle documentation and had to turn them into a detailed upgrade plan: what to change, what to add, and why. The decisions stayed with me. We support 7.4 through 8.1. Configuration should be in PHP, but the assistant should also offer YAML. The test kernel should be our own, without another dependency.

Then came the actual work. I didn't throw a single agent at everything. I split it into pieces that could run in parallel, and every agent worked in its own git worktree so they wouldn't overwrite each other's files:

  1. one agent built the foundation, the engine and a contract for everyone else,
  2. others worked in parallel on the core generated bundle, the QA tools and CI,
  3. then came the features: controllers, Twig, translations, a console command, an event listener, Doctrine, UX,
  4. an integration agent merged the branches and resolved conflicts,
  5. and finally three independent reviewers that only read the code and hunted for problems, each from a different angle: Symfony core, plugin architecture, and CI and dependencies.

That last step turned out to be the most valuable. The agents that wrote the code were convinced everything was green. The reviewers found a blocker that would have hit every single user on their very first create-project (more on that below).

One PR that outgrew itself

The original plan was a pull request to Yonel's repository. But with agents, adding things is cheap. While we're at it, let's add tests. And CI. And a proper matrix. And if the generator asks about controllers, why not a console command, a Doctrine entity or a Stimulus controller too?

When I finally ran git diff --shortstat against the original, it showed 586 changed files and nearly 30,000 added lines. More than 10,000 of those were the plugin code and templates; the rest were test fixtures.

And that's the moment I keep thinking about. This is exactly the kind of pull request that open source maintainers dread today: huge, AI-generated, from someone who didn't write a single line of it. Even if every line were correct, nobody maintaining open source in their spare time could responsibly review a change like that. Sending it wouldn't be a contribution. It would be a burden.

So I released it myself, under the MIT license, with credit to Yonel in both the LICENSE and the README. The responsibility for those 30,000 lines is mine, not his.

Symfony Bundle Maker

composer create-project tito10047/symfony-bundle-skeleton acme-blog-bundle
Enter fullscreen mode Exit fullscreen mode

The assistant asks for the package name, a description, the author (prefilled from git config), the license and the configuration format. Then it offers optional parts:

  • semantic configuration through configure(),
  • a controller with a route, a Twig template and XLIFF translations,
  • an invokable console command (the Symfony 7.3+ style with #[AsCommand] and __invoke),
  • an event listener that is opt-in: it won't touch the application's responses until the app enables it in its configuration,
  • a Doctrine entity mapped in XML, so applications can override the mapping,
  • public assets and a Stimulus controller for Symfony UX,
  • PHPStan, PHP-CS-Fixer, Rector and GitHub Actions.

Before generating anything, it shows a summary of every derived name (namespace, class, alias, translation domain, Twig namespace) and waits for confirmation. Then it runs a single composer update and uninstalls itself from the project.

The result supports Symfony 7.4 LTS and 8.1, with services defined explicitly, kept private and prefixed with the bundle alias (no autowiring inside the bundle, exactly as the best practices say). Tests come with their own TestKernel and 100% coverage right after generation. The first thing you run:

cd acme-blog-bundle
composer qa
Enter fullscreen mode Exit fullscreen mode

And it should be green.

What the reviewers and a real-world test caught

This is the part I actually wanted to write about. I didn't know most of these things, and some of them I would never have found on my own.

A Composer plugin that doesn't run plugins

After writing the files, the generator runs composer update inside its own process. Tests passed, the end-to-end runs passed. Then a reviewer noticed that phpstan/extension-installer never registered a single extension in the generated bundle. PHPStan ran without its Symfony and PHPUnit extensions, and with Doctrine enabled it crashed on an unknown parameter.

The cause: Factory::create($io, $pathToComposerJson), given a path that isn't the default one, silently sets disablePlugins = 'local' for backward compatibility reasons. The end-to-end tests missed it because they always ran one more composer update before the QA step, which masked the bug. Today CI tests the bundle exactly as the user receives it.

--prefer-lowest might be lying to you

The best practices say you should also test your bundle against the lowest allowed versions of its dependencies. Pretty much everyone does it the same way: composer update --prefer-lowest, done.

Except that since version 2.9, Composer blocks versions with known security vulnerabilities. The bundle declared symfony/http-kernel: ^7.4, and the "lowest dependencies" CI job installed 7.4.12, not 7.4.0. Instead of the declared Twig 3.21, it got 3.27. No error, no warning. CI was green, and it was testing something other than what we thought.

The fix is to turn the blocking off in the lowest job only:

composer config --global policy.advisories.block false
composer update --prefer-lowest
Enter fullscreen mode Exit fullscreen mode

Vulnerable dev dependencies in a throwaway CI container hurt nobody, and you finally test the bounds you promise your users. The older audit.block-insecure key is deprecated in Composer 2.10.

"Acme" fixtures aren't enough

At the end I asked for a real-world test: no local paths, composer create-project straight from GitHub, push the generated bundle to a new repository, and check that its CI passes on the first try. The agents had everything green, hundreds of tests and snapshots. The real bundle under my own name failed on composer cs.

Every snapshot test used vendors like Acme or MyVendor. One test template hardcoded the order of its use statements, with the bundle's namespace first. That's alphabetically correct for Acme\..., but not for Tito10047\..., because PHPUnit\... sorts before it.

The lesson applies well beyond generators: test data that looks like a documentation example mostly tests the documentation example. And an AI agent that writes both the code and its tests will happily confirm its own assumptions.

Prepending Doctrine mapping isn't innocent

The first version registered the entity mapping through prependExtension() and orm.mappings. A reviewer tried it in an application with multiple entity managers. DoctrineBundle turned the prepended configuration into an entity manager named default, which the application didn't even have. In a DBAL-only application, it switched the ORM on.

Today the bundle registers its mapping with DoctrineOrmMappingsPass in build(), with an optional model_manager_name setting, the same way FOSUserBundle and Sylius have done it for years.

Decisions the AI didn't make for me

The reviewers didn't just find bugs. They also raised questions that have no right answer, only a decision:

  • Symfony 8.0 is no longer supported, and Composer blocks its versions because of known vulnerabilities. Supporting it would mean teaching users to switch off the security audit. It's out; the range is ^7.4.17 || ^8.1.5.
  • The patch versions have a reason too. Before 7.4.17 and 8.1.5, symfony/error-handler left an exception handler registered after booting the kernel, so PHPUnit flagged every kernel test as risky. I'd rather raise the minimum version than ship fifteen lines of workaround in every generated bundle.
  • PHPStan at max level with strict rules forced 24 assertions into the tests that existed only to narrow types. I chose level 8.
  • The coding standard is plain @Symfony, the same one Symfony itself uses, with no custom deviations.
  • Rector is a local tool, not a CI gate. Otherwise every new minor release could break the build without a single change in your code.

That was my job: not writing code, but knowing what I wanted and saying no.

Bundles popping up like mushrooms after rain

Two years ago, a new bundle meant several weekends of work. Today an AI writes one in an afternoon. This generator went from the first plan to the 1.0.0 release in less than two days. We're going to see more bundles than ever before.

The code is the easy part. The hard part is making a bundle behave like a bundle: not touching the application's global behavior, naming its services properly, installing cleanly through Flex, having tests that actually test something, and CI that doesn't silently skip the lowest versions.

The generator tackles that from two sides.

Guardrails. Every generated bundle ships from its first commit with PHPStan, the Symfony coding standard, tests with coverage, and a CI matrix that fails below 95% coverage. Whether a human or an AI writes the code, the bar is the same, and it's enforced by a machine, not by good intentions.

Non-interactive mode. Every question the assistant asks can be answered with an environment variable, so a script or an AI agent can create a bundle in a single command:

BUNDLE_MAKER_NAME=acme/blog-bundle \
BUNDLE_MAKER_DESCRIPTION="Blog for Symfony applications" \
BUNDLE_MAKER_CONTROLLERS=1 \
BUNDLE_MAKER_DOCTRINE=1 \
composer create-project tito10047/symfony-bundle-skeleton acme-blog-bundle --no-interaction
Enter fullscreen mode Exit fullscreen mode

An invalid or contradictory combination, such as a listener without configuration, fails before the first file is generated, not with a half-built project.

Try it

composer create-project tito10047/symfony-bundle-skeleton my-bundle
Enter fullscreen mode Exit fullscreen mode

If something is missing, too strict, or the generated code doesn't sit right with you, open an issue. I'm most interested in hearing from people who have maintained bundles for years. The AI may have written the code, but I'm the one vouching for it.

And once more, thank you, Yonel. Your skeleton helped me ship about ten bundles. I hope this one helps others ship theirs.

Top comments (0)