Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Bundle Skeleton Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. Create from the template (or 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
    
  2. Read these files first, in this order:
    • 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.
  3. Rename the placeholder namespace. Global find/replace the vendor + bundle namespace (e.g. 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/packages skeleton, 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's bundles.php (or config/bundles.php on Symfony 4/5+).

Implementation Patterns

The three-file contract

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).

Service definition style

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.

Compiler passes for optional integrations

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.

Testing pattern

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:

  • Unit tests for plain services (fast, no kernel).
  • Functional/kernel tests that boot 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.

CI matrix

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.

Gotchas and Tips

  • The extension alias is derived, not declared. 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.
  • No auto-registration on Symfony 3.x/4.x kernels. The host app's 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.
  • Config values are 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().
  • Don't hard-require optional packages. 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/.
  • License + 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.
  • Keep the skeleton as a template, not a fork. If you fork it, you inherit its commit history and a confusing "this is a fork of a template" provenance. Use GitHub's Use this template so your first commit is a clean rename, and pin a note of which skeleton revision you started from.
  • Extension points to add yourself: the skeleton stops at "hello world" — you'll want 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource