Symfony-бандл: как вынести код в переиспользуемый модуль
Разбираем, как превратить кусок логики из проекта в отдельный установочный Symfony-бандл: где ломается автозагрузка, как правильно регистрировать сервисы и что учесть при семантическом версионировании.

Один и тот же код на кэширование внешнего API вы скопировали в три проекта. В четвёртом нашли баг, исправили — и теперь в трёх местах старая версия. Знакомая цена копипасты. Symfony-бандл решает это: логика живёт в отдельном пакете, ставится через Composer, обновляется командой composer update. Разбираем на живом примере, где именно спотыкаются при выносе кода в бандл — от неправильного namespace до сервисов, которые магически не находятся. Ориентируемся на Symfony 7.x и PHP 8.2+; на 6.4 отличий почти нет, различия отмечу отдельно.
Что такое бандл и когда он оправдан
Бандл — это Composer-пакет, который умеет встраиваться в жизненный цикл Symfony-приложения: регистрировать сервисы, добавлять конфигурацию, роуты, команды. Технически это класс, унаследованный от Symfony\Component\HttpKernel\Bundle\AbstractBundle, плюс структура каталогов вокруг него.
Оправдан бандл в двух случаях: код нужен в нескольких проектах, либо код настолько велик, что его хочется отделить от домена приложения даже в рамках одного репозитория. Для одноразовой логики внутри проекта бандл не нужен — обычного сервиса с автоконфигурацией достаточно. Порог входа у бандла выше: версионирование, документация, обратная совместимость.
Правило простое: если вы копируете код между проектами руками — это кандидат в бандл. Если код живёт в одном месте и никуда не уезжает — оставьте его сервисом.
Что было в Symfony 5 и почему сейчас проще
До Symfony 6.1 бандл требовал явного класса Extension в каталоге DependencyInjection и наследования от Bundle. Начиная с 6.1 появился AbstractBundle, который позволяет описать конфигурацию и загрузку сервисов прямо в самом классе бандла. Если вы смотрите старые статьи с классами Extension и Configuration — это рабочий, но более многословный путь. Ниже показываю современный.
Готовим структуру пакета
До начала работы вам нужны: установленный Composer, PHP 8.2 или выше, доступ к аккаунту на Packagist (если планируете публиковать) либо возможность подключить пакет через VCS-репозиторий. Проект-приёмник должен быть на Symfony Flex — иначе автоматической регистрации бандла не будет и придётся прописывать его в config/bundles.php руками.
Для примера соберём AcmeCacheBundle — тонкую обёртку над кэшированием ответов внешнего HTTP-API. Структура:
acme/cache-bundle/
├── composer.json
├── src/
│ ├── AcmeCacheBundle.php
│ └── Service/
│ └── ApiCache.php
└── config/
└── services.php
composer.json
Ключевое здесь — тип пакета symfony-bundle и правильный PSR-4 namespace. Ошибка в маппинге — самая частая причина «класс не найден» после установки.
{
"name": "acme/cache-bundle",
"description": "Кэширование ответов внешнего API",
"type": "symfony-bundle",
"license": "MIT",
"require": {
"php": ">=8.2",
"symfony/framework-bundle": "^6.4 || ^7.0",
"symfony/http-client": "^6.4 || ^7.0"
},
"autoload": {
"psr-4": {
"Acme\\CacheBundle\\": "src/"
}
}
}
Namespace Acme\CacheBundle\ указывает на каталог src/. Значит, класс AcmeCacheBundle лежит в src/AcmeCacheBundle.php и объявлен как Acme\CacheBundle\AcmeCacheBundle. Не перепутайте: имя класса и namespace — разные вещи, и Symfony ждёт именно такое сочетание.
Класс бандла и загрузка сервисов
В подходе через AbstractBundle сервисы загружаются в методе loadExtension(). Никакого отдельного Extension-класса.
<?php
// src/AcmeCacheBundle.php
namespace Acme\CacheBundle;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class AcmeCacheBundle extends AbstractBundle
{
// Описываем параметр конфигурации: время жизни кэша по умолчанию
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->integerNode('default_ttl')->defaultValue(3600)->end()
->end();
}
// Загружаем сервисы и прокидываем конфиг в контейнер
public function loadExtension(
array $config,
ContainerConfigurator $container,
ContainerBuilder $builder
): void {
$container->import('../config/services.php');
// Значение из конфига становится доступным как параметр контейнера
$container->parameters()->set('acme_cache.default_ttl', $config['default_ttl']);
}
}
Имя корневой конфигурации (в config/packages/) Symfony выводит из имени бандла автоматически: AcmeCacheBundle → acme_cache. Если хотите другое имя, переопределите метод getContainerExtension() — но по умолчанию лучше не отходить от соглашения.
Регистрация сервисов
<?php
// config/services.php
use Acme\CacheBundle\Service\ApiCache;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $container): void {
$services = $container->services()
->defaults()
->autowire()
->autoconfigure();
$services->set(ApiCache::class)
// Прокидываем параметр TTL в конструктор сервиса
->arg('$defaultTtl', '%acme_cache.default_ttl%');
};
Сервис ApiCache после установки бандла доступен для автовайринга в приложении по типу — специально ничего регистрировать не нужно.
Публичный контракт: что показывать наружу
Главная ошибка новичка в бандлах — сделать публичными все классы. После этого любое изменение внутренней реализации ломает чужие проекты, и вы обязаны выпускать мажорную версию по SemVer из-за перестановки приватного метода.
Разделяйте:
- Публичный API — сервисы, которые пользователь бандла инжектит и вызывает; интерфейсы; имена параметров конфигурации. Меняются только с ростом мажорной версии.
- Внутренняя реализация — вспомогательные классы. Пометьте их атрибутом или PHPDoc-тегом
@internal, чтобы явно сказать: сюда не лезь, ломаю без предупреждения.
Инжектите зависимости через интерфейсы, а не конкретные классы. Тогда вы вольны менять реализацию, не трогая контракт.
Локальная проверка до публикации
Не публикуйте на Packagist код, который ни разу не ставился. Подключите бандл в тестовый проект через локальный path-репозиторий. В composer.json проекта-приёмника:
{
"repositories": [
{
"type": "path",
"url": "../acme/cache-bundle"
}
],
"require": {
"acme/cache-bundle": "@dev"
}
}
После composer require acme/cache-bundle Composer создаст симлинк на локальный каталог. Правки в бандле сразу видны в проекте — удобно для отладки. Проверьте, что бандл появился в config/bundles.php (Flex добавляет его сам), и что php bin/console debug:container ApiCache показывает сервис.
Если не получилось
- «Class Acme\CacheBundle\AcmeCacheBundle not found» при загрузке ядра. Namespace в
composer.jsonне совпадает с реальным путём или именем класса. Выполнитеcomposer dump-autoloadв проекте и проверьте, что путь к файлу точно повторяет namespace. - Сервис не автовайрится, ошибка «Cannot autowire service». Файл
config/services.phpне импортируется вloadExtension(), либо у сервиса не включёнautowire(). Проверьте выводdebug:container— если сервиса там нет, контейнер его не увидел. - Бандл не появился в bundles.php. Проект не на Symfony Flex, либо тип пакета в
composer.jsonнеsymfony-bundle. Добавьте строкуAcme\CacheBundle\AcmeCacheBundle::class => ['all' => true]вconfig/bundles.phpвручную. - Конфигурация игнорируется. Имя корневого узла в
config/packages/*.yamlне совпадает с автоматически выведенным (acme_cache). Сверьте с тем, что печатаетphp bin/console config:dump-reference AcmeCacheBundle.
Версионирование и что делать дальше
Ставьте теги по SemVer: MAJOR.MINOR.PATCH. Ломающие изменения публичного контракта — только в MAJOR. Держите файл CHANGELOG.md: чужой проект должен по нему понять, что сломается при обновлении.
Дальнейшие шаги, когда базовый бандл работает:
- Добавьте тесты бандла с изолированным тестовым ядром — так вы поймаете регресс до релиза.
- Настройте матрицу CI по версиям Symfony и PHP, которые заявлены в
require. Заявили^6.4 || ^7.0— проверяйте обе. - Публикуйте на Packagist через webhook из репозитория, чтобы новые теги подхватывались автоматически.
Recipes для Symfony Flex (автоматическая раскладка конфигов при установке) — отдельная большая тема, здесь мы её не трогаем; для внутреннего бандла она не обязательна.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Symfony Docs — Best Practices for Reusable Bundles, Symfony Docs — The AbstractBundle Class
Частые вопросы
Чем бандл отличается от обычного Composer-пакета с сервисами?
А если у меня Symfony 6.4, а не 7.x — код сработает?
Обязательно ли публиковать бандл на Packagist?
Что считать ломающим изменением для SemVer в бандле?
Сервис из бандла не находится при автовайринге — с чего начать разбор?
Нужно ли выносить логику в бандл, если проект пока один?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.