DEV Community

Dev
Dev

Posted on

Symfony Init - Fast Project Bootstrap Without the Boilerplate

Every time I wanted to quickly try something with Symfony, the same story repeated itself: spin up a PHP-FPM or FrankenPHP container, exec into it, install symfony/skeleton, configure Nginx or a Caddyfile, set environment variables... All that before writing a single line of actual code.

DI container, console commands, component-based architecture... It's no secret that Symfony is heavily inspired by the Java ecosystem. So why not try to build something similar to start.spring.io?

That's how pet project https://symfony-init.dev was born.

What the User Gets

You choose the parameters on the website:

  • PHP: 8.3, 8.4, 8.5 (parsed from php.net in real time)
  • Server: PHP-FPM + Nginx or FrankenPHP
  • Symfony: latest versions (parsed from symfony.com in real time)
  • Database: PostgreSQL, MySQL, MariaDB, SQLite, or none
  • Cache: Redis or Memcached
  • Extensions: Doctrine ORM, Security, Mailer, Messenger, Validator, Serializer, API Platform, HTTP Client, Nelmio API Doc
  • Message broker: RabbitMQ

Click "Generate" - and you get a ZIP archive with a fully working project. Inside: Symfony, docker-compose.yml, Dockerfile, web server config, and a preconfigured .env.

Run a single command:

docker compose up -d --build
Enter fullscreen mode Exit fullscreen mode

That's it.

The Service Stack

Ironically, the service itself is built with Symfony 7.4, runs on FrankenPHP, and is formatted with PHP CS Fixer using the @Symfony rule set.

How It Works Internally

Project Generation

The core class is ProjectBuilder. It performs the following steps:

  1. Runs composer create-project symfony/skeleton with --no-install
  2. Generates Dockerfile, docker-compose.yml, and web server config via Twig
  3. Patches composer.json to set the required PHP version
  4. Installs selected packages using composer require
  5. Runs composer install --optimize-autoloader
  6. Cleans up Docker blocks automatically added by Symfony Flex

The last step is an important nuance. Symfony Flex injects###>bundle### blocks into docker-compose.yml when installing certain packages. To avoid duplicating services (postgres, redis, etc.), generation runs with SYMFONY_SKIP_DOCKER=1, and any leftovers are removed using a regex:

$content = preg_replace('/\n*###>.*?###.*?\n.*?###<.*?###.*?\n*/s', "\n", $content);
Enter fullscreen mode Exit fullscreen mode

Extensions are implemented using Symfony's tagging system. Each extension is a separate class tagged as app.extension. ExtensionRegistry collects them via #[TaggedIterator] and resolves dependency graphs. For example, if you select API Platform, Doctrine ORM, Serializer, and Nelmio API Doc are automatically included.

Caching

The most interesting architectural decision is filesystem-based caching.

Each combination of parameters (PHP × Symfony × server × extensions × database × cache × RabbitMQ) is converted into a stable cache key:

public function cacheKey(): string
{
    $ext = $this->extensions;
    sort($ext);

    return sprintf(
        'project_%s_%s_%s_%s_%s_%s_%s',
        $this->phpVersion,
        $this->server,
        $this->symfonyVersion,
        implode('_', $ext),
        $this->database ?? 'none',
        $this->cache ?? 'nocache',
        $this->rabbitmq ? 'rabbitmq' : 'norabbitmq'
    );
}
Enter fullscreen mode Exit fullscreen mode

The project name is intentionally excluded from the key - it only affects the folder name inside the archive, not the file contents. This allows multiple users with identical stacks to reuse the same generated project.

When a request comes in, the service checks the cache. If a match is found, it reuses the existing directory and packages it into a ZIP with the user's chosen project name. If not, it performs a full build, stores the result in var/share/projects/, and returns the path. Cache TTL is 24 hours.

$cachedPath = $this->cache->get(
    $config->cacheKey(),
    function (ItemInterface $item) use ($config): string {
        $item->expiresAfter(self::CACHE_TTL); // 86400 sec
        return $this->buildAndCache($config);
    }
);

return $this->zipper->createZip($cachedPath, $config->projectName);
Enter fullscreen mode Exit fullscreen mode

There is also a console command for cache warming:

# popular configurations only (default)
php bin/console app:warm-cache --popular-only

# all base combinations PHP × Symfony × server (no extensions/db; slower)
php bin/console app:warm-cache --all-base
Enter fullscreen mode Exit fullscreen mode

Why No Redis, No Database - and Why So Simple?

A fair question. Redis is an obvious solution for caching with TTL. But here we are caching entire project directories weighing several megabytes each. Redis operates in memory - storing full project directories there would be wasteful and against its intended use.

The filesystem is the right tool for this job. Symfony Cache natively supports cache.adapter.filesystem, storing data undervar/share/pools/. No extra infrastructure. No synchronization complexity.

A database is also unnecessary. The service has no users, no history, and no relational data requirements. Adding PostgreSQL just for rate limiting or metrics would be overengineering.

The same philosophy applies to the overall architecture. FrankenPHP supports worker mode, where the PHP process persists across requests and the application boots only once. That can significantly improve performance for high-RPS applications. But in this case, the bottleneck isn't PHP - it's Composer process execution, which takes several seconds. Worker mode wouldn't help here and would introduce state management complexity between requests. Not worth it.

Horizontal scaling raises similar issues. Multiple replicas would break the filesystem cache since each container would have isolated storage. That would require a shared volume or centralized storage, adding complexity for no real benefit at the current scale. One container. One filesystem cache. Symfony Lock for synchronization. That's enough.

Why ZIP Instead of Just composer.json?

This question defines the entire value proposition.

If the service only returned a composer.json, the user would still need PHP, Composer, manual composer install, and manual server configuration. The original problem wouldn't be solved.

The real value lies in delivering a fully built project:

  • composer install already executed
  • vendor/ included
  • composer.lock generated
  • Dockerfile with correct PHP extensions
  • docker-compose.yml with required services
  • .env preconfigured with correct DSNs

The user receives a project in a state of "unzip anddocker compose up".

The composer.lock file is crucial - it locks exact dependency versions, ensuring reproducible builds. Generating it properly without actually installing packages is impossible.

Rate Limiting & Security

The /generate endpoint is protected by a rate limiter: 30 requests per hour per IP (sliding window). It's implemented via RateLimitSubscriber, an event subscriber onkernel.controller. If the limit is exceeded, it returns HTTP 429 withRetry-After and X-RateLimit-Remaining headers.

Project generation is expensive - Composer processes take seconds. Cache absorbs most traffic (popular configurations are prewarmed and returned instantly). But intentionally generating rare combinations would cause cache misses and trigger full builds each time. On a small server, that's noticeable. The rate limiter prevents such abuse.

Small Notes

The cleanupFlexDockerFiles method relies on a regex. It works, but it's fragile. If Symfony Flex changes its block format, it could silently break. At minimum, logging a warning when cleanup behaves unexpectedly would be wise.

The project name not being part of the cache key is logically correct, but it should be explicitly documented. Since composer.json contains the package name, two users with different project names will receive identical composer.json files. For a starter project generator, that's acceptable - but potentially surprising.

Final Thoughts

The service solves a very specific problem for me: eliminating boilerplate when starting a Symfony project. Every architectural decision - worker mode, scaling, Redis - was consciously rejected because it added complexity without meaningful benefit. One container. Filesystem cache. Simple lock. Sometimes the best solution is the boring one.

The service runs on a modest VPS: 1 CPU core (2.2 GHz), 0.5 GB RAM, 10 GB HDD. If it becomes unavailable or the load grows, it can always be run locally - the repository is open source: https://github.com/bulatronic/symfony-init

Try it: https://symfony-init.dev

I'd love to get feedback, ideas, and pull requests. And a star on GitHub would be greatly appreciated ⭐

Top comments (0)