yceruto/bundle-skeleton
A lightweight skeleton for building Symfony bundles, with a clean project structure, sensible defaults, and ready-to-use tooling so you can scaffold, test, and publish reusable bundles faster.
This is a template repository, not a runtime library — you don't composer require it into an app. You clone/copy it once, rename it, and then it becomes the home of your bundle. That's the whole point: 41 stars, zero dependents, MIT — it's scaffolding you vendor by copy.
First steps
composer create-project if you prefer a copy without git history):
gh repo create acme/foo-bundle --template yceruto/bundle-skeleton --private
git clone [email protected]:acme/foo-bundle.git && cd foo-bundle
composer.json — the name, type: symfony-bundle, PSR-4 autoload/autoload-dev, and the require/require-dev split. This is the file you edit most.src/*Bundle.php — the Bundle subclass; its name drives the extension alias.src/DependencyInjection/*Extension.php and Configuration.php — the config tree + service loading pair.tests/ and phpunit.xml.dist — the harness you'll grow.Acme\FooBundle) across src/, tests/, composer.json (autoload, autoload-dev), and phpunit.xml.dist (<source> / bootstrap), then:
composer dump-autoload
vendor/bin/phpunit
If the green bar stays green, the rename is clean.First real use case: you've written a service (a Twig extension, a console command, an event subscriber, a form type) that you want to reuse across two or more Symfony apps. Drop it into src/, register it in the extension, gate any options behind Configuration, and you have a shippable bundle in an afternoon.
Laravel lens: think of it as the
laravel/packagesskeleton, except the conventions are stricter (extension alias, config tree, service loading) and there's no service-provider-equivalent auto-discovery — you register the bundle in the host app'sbundles.php(orconfig/bundles.phpon Symfony 4/5+).
Every bundle you build here revolves around one triangle. Keep the responsibilities sharp:
// src/AcmeFooBundle.php
final class AcmeFooBundle extends Bundle
{
// getContainerExtension() is auto-derived from the class name
// (AcmeFooBundle -> acme_foo). Override only if you must rename the alias.
}
// src/DependencyInjection/AcmeFooExtension.php
final class AcmeFooExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$config = $this->processConfiguration(new Configuration(), $configs);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../../config'));
$loader->load('services.xml');
// push config into services only when actually configured
if ($config['enabled']) {
$container->getDefinition('acme_foo.listener')->setArgument('$threshold', $config['threshold']);
}
}
}
// src/DependencyInjection/Configuration.php
final class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$tree = new TreeBuilder('acme_foo'); // must match the extension alias
$tree->getRootNode()
->addDefaultsIfNotSet()
->children()
->booleanNode('enabled')->defaultTrue()->end()
->integerNode('threshold')->defaultValue(10)->end()
->end();
return $tree;
}
}
Rule of thumb: Configuration owns validated user input; Extension owns wiring; the Bundle class stays nearly empty (override boot() only for things that need a booted kernel, e.g. registering a compiler pass or a Doctrine mapping when Doctrine isn't a hard dependency).
Prefer XML or PHP-DSL service files under a config/ directory over inlining definitions in the extension — it diffs cleanly, and both loaders are available via symfony/dependency-injection. Use a !php/const-friendly loader and tag services with interfaces rather than class strings where the framework supports it. If you need optional integration (Doctrine, Twig, Messenger), don't require the dependency — use interface_exists() / class_exists() guards in the extension and skip loading that service file.
When you want to decorate or collect tagged services without hard dependencies, add a compiler pass and register it in boot():
public function build(ContainerBuilder $container): void
{
parent::build($container);
$container->addCompilerPass(new CollectHandlersPass());
}
Keep the pass idempotent and tolerant of missing services; host apps will run it after their own passes.
Use symfony/framework-bundle + a tiny kernel in tests/Fixtures/ rather than testing services in isolation only. The skeleton's PHPUnit config assumes a tests/ autoload; grow it into:
AcmeFooBundle with a config.yml fixture and assert $container->get(...) wiring, tags, and config processing. This is where most bundle bugs live.Add a tests/Fixtures/Kernel.php that registers only your bundle and the minimum framework config, then reuse it across tests via a trait. This also gives you a smoke test for every Symfony version you claim to support.
The skeleton's value is that it forces you to declare a support window. Add a GitHub Actions job that runs PHPUnit across the Symfony minor versions in your require constraint — composer update --prefer-lowest in one leg and --prefer-stable in another. That catches "works on my machine's Symfony" drift before users file it.
AcmeFooBundle → alias acme_foo. If you rename the class, the Configuration root node must match the new alias or config silently won't bind. If you need a different alias, override getContainerExtension() to return a named extension — but most people forget the config tree needs the same string.type: symfony-bundle matters. Without it, Flex's bundle discovery heuristics won't recognise the package and users must add it to bundles.php manually. With it, newer Flex can auto-register. Set it in composer.json and keep it.config/bundles.php is the source of truth on modern Symfony; document the return [AcmeFooBundle::class => ['all' => true]]; line in your README. Your CI kernel fixture should mirror that to catch class-name typos.false until processed. $container->getParameter('acme_foo.foo') is null/absent until the extension runs — don't read bundle parameters in Bundle::__construct(). Do all config work in load()/boot().require-dev the integration you test against (Doctrine, Twig); suggest it in composer.json; guard the wiring at runtime. Hard-requiring Doctrine for one optional feature is how bundles get abandoned in dependency hell.services.xml vs services.yaml: both load, but XML/PHP are preferred for distributable bundles — YAML's PHP-object syntax doesn't serialise and DI tooling (container:debug, lint:container) treats them differently. Stick to one style and lint it in CI: vendor/bin/console lint:container against a throwaway app..gitattributes export-ignore. The skeleton ships dev files (tests, phpunit.xml.dist, .github). Add export-ignore for them so consumers' installs don't pull test scaffolding — otherwise composer install in a downstream app drags your whole CI into vendor/.composer.json name. The skeleton ships MIT; if you're making a private bundle, change the license to proprietary and scrub the README's template URLs — reviewers notice copy-paste leftovers.README usage docs, a CHANGELOG.md, a Makefile/composer scripts block (test, cs, stan), and a phpstan.neon baseline. Budget an hour for that bootstrap; it pays back on the second bundle.How can I help you explore Laravel packages today?