Symfony Bundle Features

Part 12 of 13 in the PHP Package Development series

This is it – the final post. In Part 11, we set up the skeleton for our Symfony bundle: the bundle class, DI extension, configuration, and service definitions. Now we’re going to make it actually useful. We’ll build Twig extensions so template authors can use our text utilities directly, create console commands for CLI workflows, wire up event subscribers for automatic text processing, and test everything properly.

By the end of this post, jakovic/text-toolkit-bundle will be a fully-featured Symfony bundle – and you’ll have completed the entire journey from “what is a package?” to shipping framework integrations.

Building Twig Extensions

Twig extensions let you add custom filters and functions that template authors can use directly in their templates. For our text toolkit, this is the most natural integration point. Instead of calling PHP methods in a controller and passing the results to a template, developers can just pipe text through a filter right in Twig.

Creating the Twig Extension Class

Twig extensions extend AbstractExtension and define filters and functions. Create src/Twig/TextToolkitExtension.php:

<?php

namespace Jakovic\TextToolkitBundle\Twig;

use Jakovic\TextToolkit\Str;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;

class TextToolkitExtension extends AbstractExtension
{
    public function __construct(
        private readonly Str $str,
    ) {
    }

    public function getFilters(): array
    {
        return [
            new TwigFilter('slugify', [$this, 'slugify']),
            new TwigFilter('truncate', [$this, 'truncate']),
            new TwigFilter('excerpt', [$this, 'excerpt']),
        ];
    }

    public function getFunctions(): array
    {
        return [
            new TwigFunction('slugify', [$this, 'slugify']),
            new TwigFunction('truncate', [$this, 'truncate']),
            new TwigFunction('excerpt', [$this, 'excerpt']),
        ];
    }

    public function slugify(string $text): string
    {
        return $this->str->slugify($text);
    }

    public function truncate(string $text, int $length = 100, string $suffix = '...'): string
    {
        return $this->str->truncate($text, $length, $suffix);
    }

    public function excerpt(string $text, int $sentences = 2): string
    {
        return $this->str->excerpt($text, $sentences);
    }
}

A few things to notice here. We’re injecting the Str service from our core library rather than duplicating logic. The extension acts as a thin bridge between Twig and our PHP classes. We’re also registering everything as both filters and functions, which gives template authors flexibility:

{# As a filter - pipe syntax, feels natural for transformations #}
{{ post.title|slugify }}
{{ post.body|truncate(150) }}
{{ post.body|excerpt(3) }}

{# As a function - useful when you're not starting with a variable #}
{{ slugify('My Blog Post Title') }}
{{ truncate(someRawText, 200, ' [...]') }}

Registering the Extension as a Service

Symfony needs to know about this extension. Add it to your bundle’s service definitions in src/Resources/config/services.yaml:

services:
    Jakovic\TextToolkitBundle\Twig\TextToolkitExtension:
        arguments:
            - '@Jakovic\TextToolkit\Str'
        tags:
            - { name: twig.extension }

The twig.extension tag is what tells Symfony to register this class as a Twig extension. Without it, Twig won’t know it exists. If you’re using Symfony’s autowiring and autoconfiguration (most modern Symfony apps do), you could also skip the manual tag – Symfony detects classes extending AbstractExtension and tags them automatically. But for a bundle, being explicit is better. You don’t want to assume the consuming application has autoconfiguration enabled.

Adding Safe HTML Output

Sometimes your text manipulation might produce HTML that you want rendered directly. For example, if you had a highlight filter that wraps matched text in <mark> tags. In that case, you’d mark the filter output as safe:

new TwigFilter('highlight', [$this, 'highlight'], ['is_safe' => ['html']]),

Our current filters just return plain text, so we don’t need this. But it’s good to know about when you’re building more advanced Twig integrations.

Creating Console Commands

Console commands give developers CLI access to your bundle’s features. This is useful for batch processing, debugging, and integration with deployment scripts. For our text toolkit, we’ll create commands that let developers slugify text and perform transformations from the terminal.

The Slugify Command

Create src/Command/SlugifyCommand.php:

<?php

namespace Jakovic\TextToolkitBundle\Command;

use Jakovic\TextToolkit\Str;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'text:slugify',
    description: 'Convert text to a URL-friendly slug',
)]
class SlugifyCommand extends Command
{
    public function __construct(
        private readonly Str $str,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('text', InputArgument::REQUIRED, 'The text to slugify');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $text = $input->getArgument('text');
        $slug = $this->str->slugify($text);

        $io->success($slug);

        return Command::SUCCESS;
    }
}

Now developers can use it from the terminal:

php bin/console text:slugify "My Blog Post Title"
# Output: my-blog-post-title

The Transform Command

Let’s build a more interesting command that can apply multiple transformations. Create src/Command/TransformCommand.php:

<?php

namespace Jakovic\TextToolkitBundle\Command;

use Jakovic\TextToolkit\Str;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'text:transform',
    description: 'Apply text transformations (slugify, truncate, excerpt)',
)]
class TransformCommand extends Command
{
    public function __construct(
        private readonly Str $str,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('text', InputArgument::REQUIRED, 'The text to transform')
            ->addOption('operation', 'o', InputOption::VALUE_REQUIRED, 'The operation: slugify, truncate, excerpt', 'slugify')
            ->addOption('length', 'l', InputOption::VALUE_REQUIRED, 'Length for truncate operation', 100)
            ->addOption('sentences', 's', InputOption::VALUE_REQUIRED, 'Number of sentences for excerpt', 2);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $text = $input->getArgument('text');
        $operation = $input->getOption('operation');

        $result = match ($operation) {
            'slugify' => $this->str->slugify($text),
            'truncate' => $this->str->truncate($text, (int) $input->getOption('length')),
            'excerpt' => $this->str->excerpt($text, (int) $input->getOption('sentences')),
            default => null,
        };

        if ($result === null) {
            $io->error(sprintf('Unknown operation "%s". Use: slugify, truncate, excerpt.', $operation));
            return Command::FAILURE;
        }

        $io->writeln('');
        $io->writeln(sprintf('<info>Operation:</info> %s', $operation));
        $io->writeln(sprintf('<info>Input:</info>     %s', $text));
        $io->writeln(sprintf('<info>Result:</info>    %s', $result));
        $io->writeln('');

        return Command::SUCCESS;
    }
}

This command is more versatile:

# Slugify (default operation)
php bin/console text:transform "Hello World"

# Truncate to 50 characters
php bin/console text:transform "A very long paragraph..." -o truncate -l 50

# Extract first 2 sentences
php bin/console text:transform "First sentence. Second sentence. Third." -o excerpt -s 2

Registering Commands as Services

Add both commands to your services.yaml:

services:
    Jakovic\TextToolkitBundle\Twig\TextToolkitExtension:
        arguments:
            - '@Jakovic\TextToolkit\Str'
        tags:
            - { name: twig.extension }

    Jakovic\TextToolkitBundle\Command\SlugifyCommand:
        arguments:
            - '@Jakovic\TextToolkit\Str'
        tags:
            - { name: console.command }

    Jakovic\TextToolkitBundle\Command\TransformCommand:
        arguments:
            - '@Jakovic\TextToolkit\Str'
        tags:
            - { name: console.command }

Like with Twig extensions, the tag is what matters here. The console.command tag tells Symfony to register these classes as available console commands. When a developer runs php bin/console list, they’ll see your text:slugify and text:transform commands in the list.

Event Subscribers

Event subscribers let your bundle react to things happening in the application. For a text toolkit, a practical use case is automatically generating slugs or transforming text when certain events fire. Let’s build a subscriber that processes response content.

Auto-Transform Subscriber

Imagine you want to automatically truncate all <meta name="description"> tags to a maximum length. This is a contrived example, but it shows the pattern well. Create src/EventSubscriber/TextTransformSubscriber.php:

<?php

namespace Jakovic\TextToolkitBundle\EventSubscriber;

use Jakovic\TextToolkit\Str;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class TextTransformSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly Str $str,
        private readonly bool $autoTruncateMetaDescription,
        private readonly int $metaDescriptionMaxLength,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::RESPONSE => 'onKernelResponse',
        ];
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        if (!$this->autoTruncateMetaDescription) {
            return;
        }

        if (!$event->isMainRequest()) {
            return;
        }

        $response = $event->getResponse();
        $content = $response->getContent();

        if ($content === false) {
            return;
        }

        $content = preg_replace_callback(
            '/(<meta\s+name="description"\s+content=")([^"]*)(">)/i',
            function (array $matches): string {
                $truncated = $this->str->truncate(
                    $matches[2],
                    $this->metaDescriptionMaxLength
                );

                return $matches[1] . $truncated . $matches[3];
            },
            $content
        );

        $response->setContent($content);
    }
}

This subscriber listens to the kernel.response event and, if configured, truncates meta descriptions that exceed the configured length. The behavior is toggled by configuration, so it’s opt-in.

Wiring the Configuration

Update your bundle’s configuration tree to support these new options. In your DependencyInjection/Configuration.php:

public function getConfigTreeBuilder(): TreeBuilder
{
    $treeBuilder = new TreeBuilder('jakovic_text_toolkit');

    $treeBuilder->getRootNode()
        ->children()
            ->booleanNode('auto_truncate_meta_description')
                ->defaultFalse()
            ->end()
            ->integerNode('meta_description_max_length')
                ->defaultValue(160)
            ->end()
        ->end();

    return $treeBuilder;
}

Then in your DependencyInjection/JakovicTextToolkitExtension.php, pass the config values to the subscriber service:

public function load(array $configs, ContainerBuilder $container): void
{
    $configuration = new Configuration();
    $config = $this->processConfiguration($configuration, $configs);

    $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
    $loader->load('services.yaml');

    // Pass config to the subscriber
    $container->getDefinition(TextTransformSubscriber::class)
        ->setArgument('$autoTruncateMetaDescription', $config['auto_truncate_meta_description'])
        ->setArgument('$metaDescriptionMaxLength', $config['meta_description_max_length']);
}

And register the subscriber in services.yaml:

    Jakovic\TextToolkitBundle\EventSubscriber\TextTransformSubscriber:
        arguments:
            $str: '@Jakovic\TextToolkit\Str'
            $autoTruncateMetaDescription: false
            $metaDescriptionMaxLength: 160
        tags:
            - { name: kernel.event_subscriber }

Now users can enable the feature in their config/packages/jakovic_text_toolkit.yaml:

jakovic_text_toolkit:
    auto_truncate_meta_description: true
    meta_description_max_length: 155

This pattern – configuration-driven behavior in event subscribers – is how well-built Symfony bundles work. The subscriber exists but does nothing unless the user explicitly turns it on. No surprises.

The Complete Bundle Structure

Before we move to testing, let’s look at what our bundle directory now looks like:

text-toolkit-bundle/
├── composer.json
├── src/
├── JakovicTextToolkitBundle.php
├── Command/
   ├── SlugifyCommand.php
   └── TransformCommand.php
├── DependencyInjection/
   ├── JakovicTextToolkitExtension.php
   └── Configuration.php
├── EventSubscriber/
   └── TextTransformSubscriber.php
├── Resources/
   └── config/
       └── services.yaml
└── Twig/
└── TextToolkitExtension.php
└── tests/
    ├── Command/
├── SlugifyCommandTest.php
└── TransformCommandTest.php
    ├── EventSubscriber/
└── TextTransformSubscriberTest.php
    ├── Functional/
└── BundleIntegrationTest.php
    └── Twig/
        └── TextToolkitExtensionTest.php

This is a clean, well-organized bundle. Every feature has its own directory, and every feature has a corresponding test directory. Let’s write those tests.

Testing Symfony Bundles

Symfony provides two key test base classes: KernelTestCase for testing services within the container, and WebTestCase for testing HTTP responses. For bundle testing, we need to set up a minimal test kernel since our bundle doesn’t come with a full application.

Setting Up the Test Kernel

First, create a minimal kernel that loads your bundle. This goes in tests/TestKernel.php:

<?php

namespace Jakovic\TextToolkitBundle\Tests;

use Jakovic\TextToolkitBundle\JakovicTextToolkitBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\HttpKernel\Kernel;

class TestKernel extends Kernel
{
    public function registerBundles(): iterable
    {
        return [
            new FrameworkBundle(),
            new TwigBundle(),
            new JakovicTextToolkitBundle(),
        ];
    }

    public function registerContainerConfiguration(LoaderInterface $loader): void
    {
        $loader->load(function ($container) {
            $container->loadFromExtension('framework', [
                'secret' => 'test',
                'test' => true,
                'http_method_override' => false,
            ]);

            $container->loadFromExtension('twig', [
                'default_path' => __DIR__ . '/templates',
            ]);

            $container->loadFromExtension('jakovic_text_toolkit', [
                'auto_truncate_meta_description' => true,
                'meta_description_max_length' => 160,
            ]);
        });
    }

    public function getCacheDir(): string
    {
        return sys_get_temp_dir() . '/JakovicTextToolkitBundle/cache';
    }

    public function getLogDir(): string
    {
        return sys_get_temp_dir() . '/JakovicTextToolkitBundle/logs';
    }
}

This kernel loads only what we need: the framework bundle for core functionality, the Twig bundle for testing Twig extensions, and our own bundle. The configuration is minimal – just enough to boot the container.

You’ll also need these test dependencies in your composer.json:

{
    "require-dev": {
        "phpunit/phpunit": "^10.0",
        "symfony/framework-bundle": "^6.4|^7.0",
        "symfony/twig-bundle": "^6.4|^7.0",
        "symfony/browser-kit": "^6.4|^7.0",
        "symfony/css-selector": "^6.4|^7.0"
    }
}

Testing Twig Extensions

There are two approaches to testing Twig extensions: unit testing the extension class directly, or integration testing through the Twig environment. Let’s do both.

First, the unit test in tests/Twig/TextToolkitExtensionTest.php:

<?php

namespace Jakovic\TextToolkitBundle\Tests\Twig;

use Jakovic\TextToolkit\Str;
use Jakovic\TextToolkitBundle\Twig\TextToolkitExtension;
use PHPUnit\Framework\TestCase;

class TextToolkitExtensionTest extends TestCase
{
    private TextToolkitExtension $extension;

    protected function setUp(): void
    {
        $this->extension = new TextToolkitExtension(new Str());
    }

    public function testSlugifyFilter(): void
    {
        $this->assertSame('hello-world', $this->extension->slugify('Hello World'));
    }

    public function testTruncateFilter(): void
    {
        $longText = str_repeat('a', 200);
        $result = $this->extension->truncate($longText, 50);

        $this->assertSame(50 + 3, strlen($result)); // 50 chars + '...'
    }

    public function testExcerptFilter(): void
    {
        $text = 'First sentence. Second sentence. Third sentence.';
        $result = $this->extension->excerpt($text, 2);

        $this->assertStringContainsString('First sentence', $result);
        $this->assertStringContainsString('Second sentence', $result);
        $this->assertStringNotContainsString('Third sentence', $result);
    }

    public function testRegistersFilters(): void
    {
        $filters = $this->extension->getFilters();
        $filterNames = array_map(fn ($f) => $f->getName(), $filters);

        $this->assertContains('slugify', $filterNames);
        $this->assertContains('truncate', $filterNames);
        $this->assertContains('excerpt', $filterNames);
    }

    public function testRegistersFunctions(): void
    {
        $functions = $this->extension->getFunctions();
        $functionNames = array_map(fn ($f) => $f->getName(), $functions);

        $this->assertContains('slugify', $functionNames);
        $this->assertContains('truncate', $functionNames);
        $this->assertContains('excerpt', $functionNames);
    }
}

These unit tests verify the extension works in isolation. But we should also test that the extension actually works inside a real Twig environment. Here’s an integration test using KernelTestCase:

<?php

namespace Jakovic\TextToolkitBundle\Tests\Twig;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Twig\Environment;

class TwigIntegrationTest extends KernelTestCase
{
    protected static function getKernelClass(): string
    {
        return \Jakovic\TextToolkitBundle\Tests\TestKernel::class;
    }

    public function testSlugifyFilterInTwig(): void
    {
        self::bootKernel();

        $twig = self::getContainer()->get('twig');
        assert($twig instanceof Environment);

        $template = $twig->createTemplate('{{ text|slugify }}');
        $result = $template->render(['text' => 'Hello World']);

        $this->assertSame('hello-world', trim($result));
    }

    public function testTruncateFilterInTwig(): void
    {
        self::bootKernel();

        $twig = self::getContainer()->get('twig');
        assert($twig instanceof Environment);

        $template = $twig->createTemplate('{{ text|truncate(10) }}');
        $result = $template->render(['text' => 'This is a long piece of text']);

        $this->assertSame('This is a ...', trim($result));
    }

    public function testSlugifyFunctionInTwig(): void
    {
        self::bootKernel();

        $twig = self::getContainer()->get('twig');
        assert($twig instanceof Environment);

        $template = $twig->createTemplate("{{ slugify('My Title') }}");
        $result = $template->render([]);

        $this->assertSame('my-title', trim($result));
    }
}

This is the gold standard for Twig extension testing. You’re testing the filters the way actual users will use them – inside real Twig templates, rendered by a real Twig environment, with your bundle properly loaded through the container.

Testing Console Commands

Symfony provides a CommandTester class specifically for testing commands. Here’s how to test our slugify command in tests/Command/SlugifyCommandTest.php:

<?php

namespace Jakovic\TextToolkitBundle\Tests\Command;

use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;

class SlugifyCommandTest extends KernelTestCase
{
    protected static function getKernelClass(): string
    {
        return \Jakovic\TextToolkitBundle\Tests\TestKernel::class;
    }

    public function testSlugifyCommand(): void
    {
        self::bootKernel();

        $application = new Application(self::$kernel);
        $command = $application->find('text:slugify');
        $tester = new CommandTester($command);

        $tester->execute(['text' => 'Hello World']);

        $tester->assertCommandIsSuccessful();
        $this->assertStringContainsString('hello-world', $tester->getDisplay());
    }

    public function testSlugifyWithSpecialCharacters(): void
    {
        self::bootKernel();

        $application = new Application(self::$kernel);
        $command = $application->find('text:slugify');
        $tester = new CommandTester($command);

        $tester->execute(['text' => 'PHP & Symfony: A Love Story!']);

        $tester->assertCommandIsSuccessful();
        $this->assertStringContainsString('php-symfony-a-love-story', $tester->getDisplay());
    }
}

And for the transform command in tests/Command/TransformCommandTest.php:

<?php

namespace Jakovic\TextToolkitBundle\Tests\Command;

use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;

class TransformCommandTest extends KernelTestCase
{
    protected static function getKernelClass(): string
    {
        return \Jakovic\TextToolkitBundle\Tests\TestKernel::class;
    }

    public function testDefaultOperationIsSlugify(): void
    {
        self::bootKernel();

        $application = new Application(self::$kernel);
        $command = $application->find('text:transform');
        $tester = new CommandTester($command);

        $tester->execute(['text' => 'Hello World']);

        $tester->assertCommandIsSuccessful();
        $this->assertStringContainsString('hello-world', $tester->getDisplay());
    }

    public function testTruncateOperation(): void
    {
        self::bootKernel();

        $application = new Application(self::$kernel);
        $command = $application->find('text:transform');
        $tester = new CommandTester($command);

        $tester->execute([
            'text' => 'This is a very long text that should be truncated',
            '--operation' => 'truncate',
            '--length' => '20',
        ]);

        $tester->assertCommandIsSuccessful();
        $this->assertStringContainsString('truncate', $tester->getDisplay());
    }

    public function testInvalidOperationFails(): void
    {
        self::bootKernel();

        $application = new Application(self::$kernel);
        $command = $application->find('text:transform');
        $tester = new CommandTester($command);

        $tester->execute([
            'text' => 'Hello',
            '--operation' => 'invalid',
        ]);

        $this->assertSame(1, $tester->getStatusCode());
        $this->assertStringContainsString('Unknown operation', $tester->getDisplay());
    }
}

Notice the pattern: boot the kernel, create an Application from it, find the command by name, and use CommandTester to execute it with arguments. You can check the exit code, the output, and any side effects. This is a true integration test – the command is resolved from the container with all its dependencies properly injected.

Testing the Event Subscriber

For the event subscriber, we can test both the subscriber logic in isolation and its integration with the kernel. Here’s the unit test in tests/EventSubscriber/TextTransformSubscriberTest.php:

<?php

namespace Jakovic\TextToolkitBundle\Tests\EventSubscriber;

use Jakovic\TextToolkit\Str;
use Jakovic\TextToolkitBundle\EventSubscriber\TextTransformSubscriber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;

class TextTransformSubscriberTest extends TestCase
{
    public function testSubscribesToResponseEvent(): void
    {
        $events = TextTransformSubscriber::getSubscribedEvents();

        $this->assertArrayHasKey(KernelEvents::RESPONSE, $events);
        $this->assertSame('onKernelResponse', $events[KernelEvents::RESPONSE]);
    }

    public function testDoesNothingWhenDisabled(): void
    {
        $subscriber = new TextTransformSubscriber(new Str(), false, 160);

        $response = new Response('<meta name="description" content="' . str_repeat('a', 200) . '">');
        $event = $this->createResponseEvent($response);

        $subscriber->onKernelResponse($event);

        // Content should be unchanged
        $this->assertStringContainsString(str_repeat('a', 200), $response->getContent());
    }

    public function testTruncatesMetaDescription(): void
    {
        $subscriber = new TextTransformSubscriber(new Str(), true, 20);

        $longDescription = str_repeat('word ', 50);
        $html = '<html><head><meta name="description" content="' . $longDescription . '"></head></html>';
        $response = new Response($html);
        $event = $this->createResponseEvent($response);

        $subscriber->onKernelResponse($event);

        $content = $response->getContent();
        // The description should have been truncated
        $this->assertStringNotContainsString($longDescription, $content);
        $this->assertStringContainsString('meta name="description"', $content);
    }

    public function testIgnoresSubRequests(): void
    {
        $subscriber = new TextTransformSubscriber(new Str(), true, 20);

        $response = new Response('<meta name="description" content="' . str_repeat('a', 200) . '">');
        $event = $this->createResponseEvent($response, HttpKernelInterface::SUB_REQUEST);

        $subscriber->onKernelResponse($event);

        // Content should be unchanged for sub-requests
        $this->assertStringContainsString(str_repeat('a', 200), $response->getContent());
    }

    private function createResponseEvent(
        Response $response,
        int $requestType = HttpKernelInterface::MAIN_REQUEST
    ): ResponseEvent {
        $kernel = $this->createMock(HttpKernelInterface::class);

        return new ResponseEvent($kernel, new Request(), $requestType, $response);
    }
}

The key test here is checking that the subscriber does nothing when disabled. Bundle features that react to kernel events must be safe by default. You don’t want your bundle modifying responses unless the user explicitly opted in.

Bundle Integration Test

Finally, let’s write a test that verifies the entire bundle loads correctly and all services are available. This catches configuration mistakes that unit tests miss. Create tests/Functional/BundleIntegrationTest.php:

<?php

namespace Jakovic\TextToolkitBundle\Tests\Functional;

use Jakovic\TextToolkit\Str;
use Jakovic\TextToolkitBundle\Command\SlugifyCommand;
use Jakovic\TextToolkitBundle\Command\TransformCommand;
use Jakovic\TextToolkitBundle\EventSubscriber\TextTransformSubscriber;
use Jakovic\TextToolkitBundle\Twig\TextToolkitExtension;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class BundleIntegrationTest extends KernelTestCase
{
    protected static function getKernelClass(): string
    {
        return \Jakovic\TextToolkitBundle\Tests\TestKernel::class;
    }

    public function testBundleBoots(): void
    {
        self::bootKernel();
        $this->assertTrue(true); // If we got here, the bundle loaded
    }

    public function testStrServiceIsRegistered(): void
    {
        self::bootKernel();

        $str = self::getContainer()->get(Str::class);
        $this->assertInstanceOf(Str::class, $str);
    }

    public function testTwigExtensionIsRegistered(): void
    {
        self::bootKernel();

        $extension = self::getContainer()->get(TextToolkitExtension::class);
        $this->assertInstanceOf(TextToolkitExtension::class, $extension);
    }

    public function testCommandsAreRegistered(): void
    {
        self::bootKernel();

        $slugifyCmd = self::getContainer()->get(SlugifyCommand::class);
        $this->assertInstanceOf(SlugifyCommand::class, $slugifyCmd);

        $transformCmd = self::getContainer()->get(TransformCommand::class);
        $this->assertInstanceOf(TransformCommand::class, $transformCmd);
    }

    public function testEventSubscriberIsRegistered(): void
    {
        self::bootKernel();

        $subscriber = self::getContainer()->get(TextTransformSubscriber::class);
        $this->assertInstanceOf(TextTransformSubscriber::class, $subscriber);
    }
}

This test might seem trivial, but it’s extremely valuable. If you rename a service, change a constructor, or misconfigure a YAML file, this test will catch it instantly. Bundle integration tests are the first tests that should pass and the first you should run.

CI for Symfony Bundles

Your CI pipeline should test the bundle against multiple Symfony versions. Bundles typically support at least two major Symfony versions. Here’s a GitHub Actions workflow:

name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: ['8.1', '8.2', '8.3']
        symfony: ['6.4.*', '7.0.*', '7.1.*']
        exclude:
          - php: '8.1'
            symfony: '7.0.*'
          - php: '8.1'
            symfony: '7.1.*'

    name: PHP ${{ matrix.php }} / Symfony ${{ matrix.symfony }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: xdebug

      - name: Install dependencies
        run: |
          composer require --no-update symfony/framework-bundle:${{ matrix.symfony }}
          composer require --no-update symfony/twig-bundle:${{ matrix.symfony }}
          composer update --prefer-dist --no-interaction

      - name: Run tests
        run: vendor/bin/phpunit

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse

Notice the exclude section. Symfony 7.x requires PHP 8.2+, so we skip those combinations for PHP 8.1. This is a common pattern for bundles – you test the version matrix that actually makes sense.

The CI also runs PHPStan. For a Symfony bundle, you’ll want a phpstan.neon that includes the Symfony extension:

parameters:
    level: 8
    paths:
        - src
        - tests

includes:
    - vendor/phpstan/phpstan-symfony/extension.neon

Don’t forget to add phpstan/phpstan-symfony to your dev dependencies. It teaches PHPStan about Symfony’s container, service types, and configuration – dramatically reducing false positives.

Bonus: Making Your Bundle Configurable at the Twig Level

A nice touch is letting users configure default behavior for your Twig filters through bundle configuration. For example, the default truncation length:

# config/packages/jakovic_text_toolkit.yaml
jakovic_text_toolkit:
    defaults:
        truncate_length: 200
        truncate_suffix: ' [...]'
        excerpt_sentences: 3

To support this, you’d expand the Configuration tree, pass the defaults to the Twig extension via the DI extension, and use them as fallbacks in the filter methods. This makes your bundle feel like a first-class Symfony citizen – everything is configurable through YAML, and nothing requires touching PHP code to customize.

Series Wrap-Up: What We Built

Let’s step back and look at what we’ve accomplished across these 12 posts.

We started with nothing – just the question “why build packages?” – and worked our way through the entire lifecycle of PHP package development:

  • Posts 1-2: We learned why packages matter and set up our first package with proper directory structure, PSR-4 autoloading, and Composer configuration.
  • Posts 3-4: We wrote real package code for jakovic/text-toolkit and tested it thoroughly with PHPUnit.
  • Posts 5-6: We published to Packagist, learned semantic versioning, wrote quality documentation, and set up CI with GitHub Actions.
  • Post 7: We explored advanced patterns – service containers, the driver pattern, and designing extensible APIs.
  • Posts 8-10: We built a full Laravel package (jakovic/laravel-text-toolkit) with service providers, Blade directives, facades, Artisan commands, and tested everything with Orchestra Testbench.
  • Posts 11-12: We built a Symfony bundle (jakovic/text-toolkit-bundle) with DI extensions, Twig filters, console commands, event subscribers, and proper bundle testing.

Three packages. One core library, two framework integrations. That’s the pattern used by virtually every successful PHP package ecosystem – league/flysystem has framework-specific adapters, spatie/laravel-permission wraps a core library, knplabs/knp-paginator-bundle wraps a standalone paginator.

Key Takeaways

If there’s one thing I want you to take away from this series, it’s this: building packages is not just for framework authors or open-source celebrities. Any developer who has written a useful utility class, a clever abstraction, or a solution to a common problem has the foundation for a package.

Here are the principles that matter most:

  • Keep your core library framework-agnostic. Framework integrations are wrappers, not the main event. Your core logic should work with plain PHP.
  • Test everything. Not just “does it work?” but “does it work when someone installs it?” Integration tests that boot the container or load the service provider are worth their weight in gold.
  • Configuration should have sane defaults. Your package should work with zero configuration. Every config option is a decision you’re pushing to the user.
  • Documentation is not optional. A README with install instructions, basic usage, and configuration options is the minimum. If people can’t figure out how to use your package in 5 minutes, they’ll pick another one.
  • Semantic versioning is a promise. When you tag v1.0.0, you’re telling users they can rely on your API. Breaking changes go in major versions. Period.

Where to Go From Here

You have the skills now. Here’s what I’d suggest as next steps:

  1. Build something you need. Look at your current project. What code do you keep copying? What problem have you solved three times? That’s your package.
  2. Study packages you admire. Clone spatie/laravel-ray, league/csv, or symfonycasts/reset-password-bundle and read their source code. Pay attention to how they structure tests, handle configuration, and write documentation.
  3. Start small. Your first package doesn’t need to be revolutionary. A simple utility that saves developers 10 minutes is valuable. Ship it, get feedback, iterate.
  4. Contribute to existing packages. Before building something from scratch, check if an existing package does 80% of what you need. A pull request is sometimes better than a new package.
  5. Maintain what you ship. Respond to issues, review pull requests, and keep dependencies updated. A maintained package with 50 stars builds more trust than an abandoned one with 500.

The PHP Ecosystem Needs You

Seriously. The PHP ecosystem has over 400,000 packages on Packagist, but there are always gaps. Maybe you’ve built a better way to validate phone numbers, a cleaner API for working with PDFs, or a smarter caching strategy. Whatever it is, if it solves a real problem, other developers will find it and use it.

Every major package started as someone scratching their own itch. Carbon started because working with dates in PHP was painful. Flysystem started because filesystem abstraction didn’t exist. PHPStan started because one developer wanted better static analysis.

Your package could be next.

Thanks for following along through all 12 posts. Now go build something.

Found this useful? Share it with your network.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.