Building a Custom Symfony Bundle from Scratch: Reusable Components
AI generated
SF
{ }
Symfony · Bundles · Reusability · PHP
Building a Custom Symfony Bundle from Scratch
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.

18 min read Bundle class · Composer · Kernel · Structure Symfony 7.x · PHP 8.3+

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.

11. FAQ: Building a Custom Symfony Bundle from Scratch

1Custom bundle instead of copying code?
As soon as the same logic is needed in a second or third project. Copy pasting leads to diverging code over time.
2Does every bundle need an extension class?
No, Symfony finds it automatically with simple conventions. Complex configuration benefits from a dedicated class.
3How to test a bundle in isolation?
With a minimal test kernel in the tests directory that only loads the bundle itself.
4require vs. require-dev in composer.json?
require holds only real runtime dependencies, testing tools belong in require-dev.
5Internal bundle without Packagist?
Via Composer path repositories locally, or via Private Packagist or Satis for real releases.
6Does bundles.php need manual edits?
Only for internal bundles without a Flex recipe. Public bundles get added automatically.
7Avoiding too tight coupling?
Use the configuration tree for project specific values, prefer interfaces over concrete classes.
8Which Symfony version in composer.json?
A realistic range such as ^6.4 || ^7.0 instead of a single fixed version.
9Own routes and commands in a bundle?
Yes, through a dedicated routing file and normal services with the console.command tag.
10Most common beginner mistake?
Missing configurability: hardcoded values instead of a configuration tree, so the bundle only works in one project.