From copy pasted code to a reusable component
Anyone repeating the same code across several Symfony projects should build a custom Symfony bundle. This article walks through the full directory structure, the bundle class, the Composer package and kernel registration, so recurring logic becomes a maintained, testable component.
Table of contents
- 1. When a custom Symfony bundle pays off
- 2. The standard directory structure of a bundle
- 3. The bundle class as the entry point
- 4. Composer package: setting up composer.json correctly
- 5. Registration in the target project
- 6. Services, configuration and autoconfigure in the bundle
- 7. Local development with Composer path repositories
- 8. Common pitfalls with your first bundle
- 9. Bundle vs. library vs. copy paste compared
- 10. Summary
- 11. FAQ
1. When a custom Symfony bundle pays off
A Symfony bundle is the framework's official extension unit: a self contained package of code, configuration, resources and optionally assets that can be plugged into any number of Symfony applications. The point where a custom bundle pays off is reached as soon as a team finds the same logic in a second or third project: an audit log feature, a multi tenancy filter, an integration with an internal payment service. Copy pasting between projects works in the short term, but it produces diverging code over time, because bug fixes only land in one of the projects.
A Symfony bundle solves this problem by versioning the logic in exactly one place and pulling it into every project through Composer. Unlike a plain PHP package, a bundle brings full integration into the Symfony kernel: its own services in the container, its own configuration options, its own routes and commands. Anyone who only wants to share a few helper functions does not need a bundle, a normal Composer library is enough. But as soon as dependency injection, configuration validation or kernel hooks are required, a custom bundle is the right answer.
It is important to distinguish this from kernel configuration itself: a bundle is not a replacement for architectural decisions in the main project, it is an extraction of cross cutting functionality. Anyone planning a Symfony bundle properly thinks about the public API of the component first, and only then about the implementation. This order prevents internal details of the bundle from leaking out and making later version upgrades unnecessarily hard.
2. The standard directory structure of a bundle
Since Symfony 4 the directory structure of a bundle has become much leaner than before, yet it still follows a clear convention. The root directory holds composer.json, the bundle class and a src directory for the actual PHP code. Inside src there is typically a DependencyInjection directory for the extension class and the configuration tree, a Resources or config directory for service definitions in YAML or PHP, and domain specific subdirectories such as Service, Repository or EventListener depending on what the bundle contains.
A key difference from an application: a Symfony bundle has no kernel of its own and no config/packages structure at runtime, it only delivers definitions that the host kernel reads in. That is why service definitions inside a bundle usually live under config/services.php or Resources/config/services.yaml, depending on whether the team prefers PHP based or YAML based configuration. Tests belong in a separate top level tests directory, so they do not accidentally end up in the production autoloading.
# Typical directory layout of a standalone Symfony Bundle
acme-audit-bundle/
├── composer.json
├── README.md
├── src/
│ ├── AcmeAuditBundle.php
│ ├── DependencyInjection/
│ │ ├── AcmeAuditExtension.php
│ │ └── Configuration.php
│ ├── EventListener/
│ │ └── AuditLogListener.php
│ ├── Service/
│ │ └── AuditLogger.php
│ └── Repository/
│ └── AuditEntryRepository.php
├── config/
│ └── services.php
└── tests/
└── Service/
└── AuditLoggerTest.php
3. The bundle class as the entry point
Every Symfony bundle needs exactly one class that extends Symfony\Component\HttpKernel\Bundle\Bundle. This class is the entry point the host kernel talks to when booting. In most cases the empty default implementation is enough, because Symfony automatically finds the matching extension class in the DependencyInjection namespace through convention, as long as the name and namespace line up. Anyone who does not want to rely on this convention, or who needs to register custom compiler passes, overrides build() in the bundle class.
Since Symfony 5.3 there is also the option to define an extension directly in the bundle class through an attribute or method, without creating a separate extension class, which reduces boilerplate for small bundles. For larger bundles with many configuration options, the classic separation between the bundle class and the extension class remains useful though, because it cleanly separates responsibilities: the bundle class handles kernel integration, the extension class handles loading configuration.
// src/AcmeAuditBundle.php
declare(strict_types=1);
namespace Acme\AuditBundle;
use Acme\AuditBundle\DependencyInjection\Compiler\AuditSubscriberPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
/**
* Entry point of the reusable audit logging bundle.
*/
final class AcmeAuditBundle extends Bundle
{
public function build(ContainerBuilder $container): void
{
parent::build($container);
// Register a custom compiler pass that collects tagged audit subscribers
$container->addCompilerPass(new AuditSubscriberPass());
}
}
4. Composer package: setting up composer.json correctly
For a Symfony bundle to be distributed as its own Composer package, it needs a unique vendor/package name, a PSR-4 autoload declaration and the right require entries for symfony/framework-bundle and symfony/dependency-injection. The type value symfony-bundle is optional, but it helps tooling such as Composer plugins categorize the package correctly. A realistic version constraint on the Symfony core components matters, so the bundle is neither too tightly nor too loosely tied to a Symfony version.
A common mistake with a first bundle: too many concrete dependencies end up in require instead of require-dev, such as PHPUnit or a test kernel. That bloats the dependency chain of every project that pulls in this Symfony bundle. Anything only needed to test the bundle itself belongs consistently in require-dev, while require only contains the bundle's actual runtime dependencies.
{
"name": "acme/audit-bundle",
"type": "symfony-bundle",
"description": "Reusable audit logging bundle for Symfony applications",
"license": "MIT",
"require": {
"php": ">=8.2",
"symfony/framework-bundle": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/config": "^6.4 || ^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"symfony/phpunit-bridge": "^7.0"
},
"autoload": {
"psr-4": { "Acme\\AuditBundle\\": "src/" }
},
"autoload-dev": {
"psr-4": { "Acme\\AuditBundle\\Tests\\": "tests/" }
}
}
5. Registration in the target project
A finished Symfony bundle is registered in the target project through the config/bundles.php file, which Symfony Flex normally maintains automatically as soon as an official recipe entry exists. For internal bundles that are not published on Packagist.org there is no automatic recipe, so the entry needs to be added manually. The entry maps the bundle class to the environments in which it should be active, usually all for every environment, or a restriction to dev and test for pure debugging bundles.
After registration, the cache must be cleared so the container picks up the new bundle and its services. A common pitfall: if the bundle is not added to bundles.php after the Composer require, Symfony loads neither the extension nor any services, without giving a clear error message, which unnecessarily prolongs debugging.
// config/bundles.php in the host application
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
// Register the internal audit bundle for all environments
Acme\AuditBundle\AcmeAuditBundle::class => ['all' => true],
];
6. Services, configuration and autoconfigure in the bundle
A well designed Symfony bundle registers its services through an extension class loaded by the host container, instead of relying on the host project's global autowiring rules. That gives the bundle author full control over which services are public, which are private, and which get an alias visible to the application. Autoconfigure inside the bundle works exactly as it does in a regular application, but it must be explicitly scoped to the bundle's own namespace, so it does not accidentally pick up unrelated classes from the host project.
Configuration options that the target project is allowed to adjust, such as the name of the audit log table or the retention period of entries, are defined through the configuration tree and evaluated in the extension class to produce container parameters. These parameters are then available to all services in the bundle and can be overridden in the target project under a dedicated configuration key, such as acme_audit.
7. Local development with Composer path repositories
While developing a new Symfony bundle, it is impractical to push a new git tag after every change and run composer update in the target project. Composer offers path repositories for exactly this: the target project references the bundle through a local filesystem path, and Composer by default creates a symlink, so changes in the bundle repository are visible in the target project immediately, without requiring a fresh require.
This workflow is especially suited to monorepo like setups, where several internal Symfony applications and one or more shared bundles live in the same workspace. Once the bundle is stable enough for real releases, the path reference is replaced with an actual version constraint pointing to a private Composer repository such as Private Packagist or a Satis setup.
{
"repositories": [
{
"type": "path",
"url": "../acme-audit-bundle",
"options": { "symlink": true }
}
],
"require": {
"acme/audit-bundle": "*"
}
}
8. Common pitfalls with your first bundle
The most common mistake with a first custom Symfony bundle is coupling it too tightly to the original project the code was extracted from. Fixed values, project specific entity classes or hardcoded route names prevent the bundle from working at all in a second project. The fix is consistent configurability through the configuration tree, and interfaces instead of concrete classes for anything that can vary between projects.
A second pitfall is missing version discipline: if a bundle is maintained without semantic versioning and without a changelog, dependent projects have no way to know whether an update is safe. A third, often underestimated mistake is missing tests inside the bundle repository itself. Without a minimal test kernel that boots the bundle in isolation, it often only becomes apparent in the host project that a change broke configuration, which makes debugging considerably harder.
9. Bundle vs. library vs. copy paste compared
Not every piece of reused code justifies a full Symfony bundle. The table below compares the three common approaches and shows when each one makes sense.
| Criterion | Copy paste | Plain Composer library | Symfony bundle |
|---|---|---|---|
| Container integration | None | Manual in host | Automatic via extension |
| Configurability | None | Constructor only | Configuration tree with validation |
| Maintenance effort | Diverges per project | Central, no kernel hooks | Central, full integration |
| Suited for | Nothing, transitional only | Pure utility functions | Services, routes, commands, events |
| Initial testing effort | None | Low | Higher, test kernel needed |
The table shows: a Symfony bundle has a higher initial cost than a simple library, but it clearly pays off from the third consuming project onward and whenever kernel integration is actually needed. Anyone unsure should start with a library and only move to a bundle once configurability or service registration is truly required.
Mironsoft
Symfony architecture, bundle development and internal component libraries
Recurring code across several Symfony projects?
We extract shared logic from existing Symfony applications into dedicated, versioned bundles, with clean configuration, tests and Composer registration for your whole team.
Bundle extraction
Analyzing existing projects and extracting reusable components
Composer setup
Setting up Private Packagist or a Satis repository for internal bundles
Long term maintenance
Versioning, changelogs and tests for stable bundle releases
10. Summary
A custom Symfony bundle pays off as soon as the same code is needed in more than one project and kernel integration such as own services, configuration or commands is required. The standard directory structure with src, DependencyInjection and config keeps things consistent with the Symfony ecosystem. The bundle class is the entry point, the extension class loads services and evaluates configuration. Composer with a clean split between require and require-dev dependencies makes the bundle distributable as its own package.
For local development, Composer path repositories are the fastest route, without constant releases. When moving from copy paste to a real bundle, it pays to focus on configurability through interfaces rather than concrete classes, so the bundle actually works across multiple projects. Anyone who follows these fundamentals builds a Symfony bundle that stays maintainable long term and integrates into new projects without friction.
Building a Custom Symfony Bundle from Scratch — At a glance
When a bundle pays off
From the second or third project reusing the same logic, once kernel integration is required.
Core structure
Bundle class as the entry point, extension class for services and configuration.
Composer package
PSR-4 autoload, clean split of require and require-dev, a realistic version constraint.
Local development
Composer path repositories with symlinks instead of constant git tags during development.