Introduction #
In the landscape of modern PHP development (circa 2025), the line between a “framework developer” and a “library creator” has blurred significantly. We are no longer just writing glue code for Laravel or Symfony applications; we are architecting robust, standalone packages that can be dropped into any environment.
Why is this important? Because reusability is the holy grail of software engineering.
If you write a logic-heavy module coupled tightly to a specific framework, you limit its lifespan and utility. However, by leveraging Symfony Components as standalone building blocks, you can create powerful, configurable, and highly extensible libraries that behave professionally—handling configuration validation, dependency injection, and CLI commands—without forcing the end-user to adopt the entire Symfony Full Stack Framework.
In this deep-dive guide, we are going to build a library called ReportCraft. It will be a data export engine capable of handling multiple formats (JSON, CSV). We won’t just write the classes; we will build the infrastructure around them using:
symfony/config: To validate and process user configuration.symfony/dependency-injection: To wire up our services automatically.symfony/console: To provide a CLI interface for the library.
By the end of this article, you will possess the skills to structure your own open-source packages like the top vendors in the ecosystem.
Prerequisites and Environment #
Before we dive into the code, ensure your environment is ready. We are targeting the standards prevalent in 2025.
- PHP Version: PHP 8.2 or higher (PHP 8.4 recommended for better syntax sugar).
- Composer: Version 2.7+.
- IDE: PhpStorm (recommended) or VS Code.
- Git: For version control.
The Project Structure #
We will create a directory structure that mirrors professional package development.
mkdir report-craft
cd report-craft
composer initFollow the prompts. For the sake of this tutorial, define the package name as vendor/report-craft.
Your directory structure should eventually look like this:
report-craft/
├── bin/
│ └── report-craft # CLI entry point
├── config/ # Default configuration files
├── src/
│ ├── Command/ # CLI Commands
│ ├── DependencyInjection/ # DI Extension logic
│ ├── Exporter/ # Business Logic
│ └── ReportCraft.php # Main entry point (Facade)
├── tests/
├── composer.json
└── vendor/We need to require our dependencies. Since we are building a library, we want to be specific.
composer require symfony/config symfony/dependency-injection symfony/yaml symfony/console symfony/filesystem
composer require --dev phpunit/phpunitUpdate your composer.json to configure autoloading:
{
"name": "yourname/report-craft",
"description": "A robust reporting library using Symfony Components.",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"ReportCraft\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"ReportCraft\\Tests\\": "tests/"
}
},
"require": {
"php": ">=8.2",
"symfony/config": "^7.0",
"symfony/console": "^7.0",
"symfony/dependency-injection": "^7.0",
"symfony/filesystem": "^7.0",
"symfony/yaml": "^7.0"
}
}Run composer dump-autoload to finalize the setup.
Part 1: The Core Business Logic #
Before we get fancy with configuration and containers, we need something to actually do. Let’s create a simple Export system.
The Interfaces #
Always program to an interface. Create src/Exporter/ExporterInterface.php:
<?php
namespace ReportCraft\Exporter;
interface ExporterInterface
{
/**
* Supports checks if this exporter handles the given format.
*/
public function supports(string $format): bool;
/**
* Export data to a file.
*/
public function export(array $data, string $destination): void;
}The Concrete Implementations #
Let’s create a JSON exporter. Create src/Exporter/JsonExporter.php:
<?php
namespace ReportCraft\Exporter;
use Symfony\Component\Filesystem\Filesystem;
class JsonExporter implements ExporterInterface
{
public function __construct(
private readonly Filesystem $filesystem,
private readonly bool $prettyPrint = false
) {}
public function supports(string $format): bool
{
return strtolower($format) === 'json';
}
public function export(array $data, string $destination): void
{
$flags = JSON_THROW_ON_ERROR;
if ($this->prettyPrint) {
$flags |= JSON_PRETTY_PRINT;
}
$content = json_encode($data, $flags);
// Ensure directory exists
$this->filesystem->mkdir(dirname($destination));
$this->filesystem->dumpFile($destination, $content);
}
}(Note: We would typically create a CsvExporter as well, but for brevity, we will stick to one concrete class and assume the logic extends easily.)
Part 2: Defining Configuration with symfony/config
#
This is where the magic begins. Instead of passing arrays blindly into our classes, we define a strict schema. This provides validation, default values, and helpful error messages to the developer using your library.
We need a Configuration class. Create src/DependencyInjection/Configuration.php.
<?php
namespace ReportCraft\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('report_craft');
// Root node
$treeBuilder->getRootNode()
->children()
->scalarNode('default_path')
->defaultValue('./var/reports')
->info('The default directory where reports are saved.')
->end()
->arrayNode('formats')
->addDefaultsIfNotSet()
->children()
->arrayNode('json')
->addDefaultsIfNotSet()
->children()
->booleanNode('pretty_print')
->defaultTrue()
->end()
->end()
->end() // end json
->arrayNode('csv')
->children()
->scalarNode('delimiter')->defaultValue(',')->end()
->end()
->end() // end csv
->end()
->end() // end formats
->end()
;
return $treeBuilder;
}
}Why do this?
If a user tries to configure pretty_print as a string “yes” instead of a boolean, the component will throw a descriptive exception immediately. This is much better than runtime logic errors.
Part 3: The Dependency Injection Container (DIC) #
Now we need to wire our services. In a library, we use an Extension class to load services and process the configuration we defined above.
Service Definitions #
Create a config/services.php file. Using PHP for service definition is the modern standard (over YAML) because it offers IDE autocompletion and refactoring support.
<?php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
use ReportCraft\Exporter\JsonExporter;
use ReportCraft\ReportCraft;
use Symfony\Component\Filesystem\Filesystem;
return static function (ContainerConfigurator $configurator) {
$services = $configurator->services()
->defaults()
->autowire() // Automatically inject dependencies based on type hints
->autoconfigure() // Automatically register tags (like console commands)
;
// Register Filesystem component
$services->set(Filesystem::class);
// Register our Exporter
$services->set(JsonExporter::class)
->arg('$prettyPrint', '%report_craft.json.pretty_print%'); // Inject param
// Register the Main Facade (Entry point)
$services->set(ReportCraft::class)
->public(); // This needs to be public to be retrieved directly
};The Extension Class #
Create src/DependencyInjection/ReportCraftExtension.php. This class bridges the config and the container.
<?php
namespace ReportCraft\DependencyInjection;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\Config\FileLocator;
class ReportCraftExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
// 1. Process Configuration
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
// 2. Set Parameters in the Container
// We flatten the config array into container parameters for injection
$container->setParameter('report_craft.default_path', $config['default_path']);
$container->setParameter('report_craft.json.pretty_print', $config['formats']['json']['pretty_print']);
// 3. Load Service Definitions
$loader = new PhpFileLoader(
$container,
new FileLocator(__DIR__ . '/../../config')
);
$loader->load('services.php');
}
}Visualizing the Flow #
It can be hard to visualize how these components interact. Here is the lifecycle of our library initialization:
Part 4: The Main Facade #
To make the library easy to use, we expose a main class ReportCraft. This class will use the tagged_iterator pattern if we had multiple exporters, but for now, we’ll keep it simple by injecting the JsonExporter.
Create src/ReportCraft.php:
<?php
namespace ReportCraft;
use ReportCraft\Exporter\JsonExporter;
class ReportCraft
{
public function __construct(
private readonly JsonExporter $jsonExporter,
private readonly string $defaultExportPath // Injected param
) {}
public function generateReport(array $data, ?string $filename = null): void
{
$path = $filename ?? $this->defaultExportPath . '/report_' . date('Ymd_His') . '.json';
// In a real app, we would select the exporter based on extension
// For this demo, we force JSON
if ($this->jsonExporter->supports('json')) {
$this->jsonExporter->export($data, $path);
echo "Report generated at: {$path}\n";
}
}
}Update config/services.php to inject the path:
$services->set(ReportCraft::class)
->public()
->arg('$defaultExportPath', '%report_craft.default_path%');Part 5: Bootstrapping the Library #
This is the critical part that most tutorials miss. How do you actually run this outside of a framework like Symfony or Laravel? You must manually compile the container.
Create a usage script in the root directory example.php:
<?php
require_once __DIR__ . '/vendor/autoload.php';
use Symfony\Component\DependencyInjection\ContainerBuilder;
use ReportCraft\DependencyInjection\ReportCraftExtension;
// 1. Initialize Container
$container = new ContainerBuilder();
// 2. Register Extension
$extension = new ReportCraftExtension();
$container->registerExtension($extension);
// 3. Load Configuration (Simulating user config)
$container->loadFromExtension('report_craft', [
'default_path' => __DIR__ . '/output',
'formats' => [
'json' => ['pretty_print' => true]
]
]);
// 4. Compile the Container
// This resolves all dependencies, parameters, and optimizations.
$container->compile();
// 5. Use the Library
try {
$engine = $container->get(\ReportCraft\ReportCraft::class);
$data = [
'user' => 'John Doe',
'stats' => ['views' => 150, 'clicks' => 12]
];
$engine->generateReport($data);
} catch (\Exception $e) {
echo "Error: " . $e->getMessage();
}Run this script: php example.php. You should see a file created in an output folder with pretty-printed JSON.
Part 6: Adding a CLI with symfony/console
#
A good library often provides a CLI tool. Since we are already using the Container, integrating Console is trivial.
Create src/Command/GenerateReportCommand.php:
<?php
namespace ReportCraft\Command;
use ReportCraft\ReportCraft;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'report:generate', description: 'Generates a dummy report')]
class GenerateReportCommand extends Command
{
public function __construct(private readonly ReportCraft $reportCraft)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('<info>Generating Report...</info>');
$this->reportCraft->generateReport(['source' => 'CLI']);
$output->writeln('<info>Done!</info>');
return Command::SUCCESS;
}
}Now create the executable binary bin/report-craft:
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
use Symfony\Component\Console\Application;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use ReportCraft\DependencyInjection\ReportCraftExtension;
use ReportCraft\Command\GenerateReportCommand;
// Setup Container (same as example.php)
$container = new ContainerBuilder();
$extension = new ReportCraftExtension();
$container->registerExtension($extension);
$container->loadFromExtension('report_craft', []); // Load defaults
// Register Command specifically
$container->register(GenerateReportCommand::class)
->setAutowired(true)
->setPublic(true);
$container->compile();
// Run Application
$application = new Application('ReportCraft', '1.0.0');
$application->add($container->get(GenerateReportCommand::class));
$application->run();Make it executable: chmod +x bin/report-craft.
Run it: ./bin/report-craft report:generate.
Performance and Architecture Comparison #
Why go through all this trouble instead of just writing a static class? Let’s compare the “Simple” approach vs. the “Component” approach.
| Feature | Monolithic / Static Helper | Symfony Component Based |
|---|---|---|
| Coupling | High (Hardcoded dependencies) | Low (Dependencies via Interface Injection) |
| Configuration | Arrays (No validation, typo-prone) | Schema-based (Strict types, validation, defaults) |
| Integration | Manual instantiation everywhere | Auto-wired into Frameworks (Laravel/Symfony) |
| Performance | Runtime logic checks | Compiled Container (Zero config overhead at runtime) |
| Extensibility | Hard (Must modify core code) | Easy (Decorators, Events, custom Services) |
The Power of Compiled Containers #
When you run $container->compile(), Symfony resolves all the messy logic. It checks if dependencies exist, resolves circular references, and freezes the configuration.
In a production environment (like a Symfony full-stack app), this container is cached to a PHP file. This means zero parsing cost for your configuration files on subsequent requests. This is the secret to high-performance PHP applications.
Best Practices & Common Pitfalls #
1. Semantic Versioning #
When building libraries, you are responsible for other people’s stability.
- Do not break BC (Backward Compatibility) in minor versions.
- If you change the
Configurationtree structure (e.g., rename a node), that is a Breaking Change.
2. The composer.lock Dilemma
#
- Libraries: Should NOT commit
composer.lock. You want your library to be tested against the latest versions of dependencies compatible with your constraints. - Applications: SHOULD commit
composer.lock.
3. Exposing Services #
In services.php, default to private services. Only make the “Entry Point” classes (like ReportCraft or specific Commands) public. This prevents users from relying on internal helper classes that you might want to refactor later.
4. Unit Testing the Extension #
Don’t just test your logic; test your configuration. Ensure that if a user passes invalid config, your library throws the expected exception.
// tests/DependencyInjection/ReportCraftExtensionTest.php
public function testInvalidConfigurationThrowsException(): void
{
$this->expectException(\Symfony\Component\Config\Definition\Exception\InvalidConfigurationException::class);
$loader = new ReportCraftExtension();
$loader->load([['formats' => ['json' => ['pretty_print' => 'yes']]]], new ContainerBuilder()); // String 'yes' instead of bool
}Conclusion #
Building a PHP library in 2025 is about providing a Developer Experience (DX) that matches the quality of the frameworks we use daily. By utilizing symfony/config and symfony/dependency-injection, you elevate your code from a “script” to a professional “package.”
You have learned how to:
- Structure a PSR-4 compliant library.
- Define strict configuration schemas (goodbye, cryptic array keys!).
- Wire dependencies automatically using a Container.
- Expose functionality via CLI.
Next Steps:
- Implement
symfony/options-resolverfor smaller, runtime configuration needs inside classes. - Add GitHub Actions to run your tests on every push.
- Publish your package to Packagist.org.
Your code is now ready for the world. Go build something reusable.
Found this guide helpful? Subscribe to PHP DevPro for more architectural deep dives.