What is Symfony?
Symfony is two things at once. It is a set of reusable PHP components — HttpFoundation, Routing, Console, Mailer, Serializer and dozens more — and it is a full web framework assembled from those components. That dual nature explains almost everything about it: the framework is stable because its parts are stable, and its parts are useful on their own.
Created by Fabien Potencier in 2005 and maintained by SensioLabs, Symfony has become the engine room of modern PHP. Laravel is built on Symfony components, Drupal and phpBB depend on them, and thousands of libraries use them without ever touching the full framework.
Symfony asks more of you than a batteries-included framework. In return it gives you a system you can reason about, extend and upgrade for years.
Components: the real product
The components are the heart of the project. Each one solves a single problem and has no dependency on the framework.
- HttpFoundation — object-oriented
RequestandResponseclasses. - Routing — map URLs to controllers with attributes, YAML or PHP.
- Console — build command-line tools with arguments and options.
- Mailer — send email through transports and templated messages.
- Serializer — convert objects to JSON, XML and back.
- EventDispatcher — decouple code with events and listeners.
You can install any of them directly:
composer require symfony/http-foundation
use Symfony\Component\HttpFoundation\Request;
$request = Request::createFromGlobals();
$page = $request->query->getInt('page', 1);
That portability is why Symfony components show up everywhere, even in projects that would never adopt the full framework.
The HTTP kernel and controllers
In a full application, every request flows through the HttpKernel. It dispatches an event, matches a route, calls a controller and turns the returned Response into output. Controllers extend AbstractController to get convenient helpers.
class BlogController extends AbstractController
{
#[Route('/blog/{slug}', name: 'blog_show')]
public function show(string $slug, PostRepository $posts): Response
{
$post = $posts->findOneBy(['slug' => $slug]);
if (!$post) {
throw $this->createNotFoundException('Post not found');
}
return $this->render('blog/show.html.twig', ['post' => $post]);
}
}
A controller must return a Response. $this->render() builds one from a Twig template, $this->json() returns JSON, and $this->redirectToRoute() returns a redirect. Because the response object is explicit, testing and middleware are straightforward.
Twig templates extend a base layout and fill in blocks, and output is escaped unless you opt out:
{# templates/blog/show.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.publishedAt|date('F j, Y') }}</p>
{{ post.body|raw }}
</article>
{% endblock %}
Attribute routing
Routes are declared next to the action that handles them using PHP 8 attributes.
#[Route('/blog', name: 'blog_index', methods: ['GET'])]
#[Route('/blog/{slug}', name: 'blog_show', methods: ['GET'])]
Routing can also live in config/routes.yaml when you prefer to keep it separate. Attributes are the default in modern Symfony because the route and the method stay together. Inspect what is registered at any time with:
php bin/console debug:router
The service container and autowiring
Almost everything in Symfony is a service, and the container builds and connects them. With autowiring enabled, you only need to type-hint a constructor.
class PostPublisher
{
public function __construct(
private EntityManagerInterface $em,
private MailerInterface $mailer,
) {}
}
config/services.yaml tells the container to autowire your classes:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Entity/'
- '../src/Kernel.php'
When two implementations of an interface exist, bind the right one explicitly. The container is compiled into plain PHP for production, which is why it is fast despite being so dynamic to write.
Bundles and Flex recipes
A bundle packages features into a distributable unit. Third-party functionality — Doctrine, security, mailer, admin panels — arrives as a bundle, and the framework itself is composed of core bundles.
Installing one is a single command:
composer require symfony/orm-pack
Symfony Flex is the Composer plugin that makes this pleasant. When a package has a recipe, Flex creates the config files, registers the bundle, and often adds environment variables or a docker-compose service. That is why modern Symfony apps have small, explicit configuration instead of pages of boilerplate.
Doctrine ORM
Doctrine is the default persistence layer. Entities are plain PHP classes annotated with mapping attributes, and repositories encapsulate queries.
#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(type: 'text')]
private string $body;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $publishedAt = null;
}
Queries are usually expressed with the repository API or DQL rather than SQL strings:
$latest = $posts->findBy([], ['publishedAt' => 'DESC'], limit: 10);
Schema changes are managed with Doctrine Migrations, which compare your mapping to the database and generate versioned migration files you can review before running.
Configuration and environments
Symfony separates configuration from code and applies it per environment. APP_ENV selects the environment, and files under config/ layer on top of each other.
.envholds local defaults and environment variables.config/packages/configures bundles for every environment.config/packages/dev/andconfig/packages/prod/override per environment.config/routes.yamlandconfig/services.yamlare the app’s own settings.
The dev environment enables the profiler and debug tools, test uses a separate database, and prod compiles everything for speed. Clearing cache after config changes is a normal part of the workflow:
php bin/console cache:clear
Messenger and async work
The Messenger component moves work out of the request. You dispatch a message to a bus and a handler processes it, either immediately or on a worker.
final class UserRegistered
{
public function __construct(public readonly int $userId) {}
}
#[AsMessageHandler]
final class SendWelcomeEmail
{
public function __invoke(UserRegistered $message): void
{
// send the email here
}
}
Dispatch it from anywhere, and consume the queue in a separate process:
$bus->dispatch(new UserRegistered($user->getId()));
php bin/console messenger:consume async
Transports can be Doctrine, Redis, AMQP or a third-party service, and failed messages can be retried or stored, which makes background processing a first-class concern.
Testing with PHPUnit
Symfony ships with PHPUnit and a WebTestCase base class that boots the kernel and makes real HTTP requests.
class BlogControllerTest extends WebTestCase
{
public function testShowPost(): void
{
$client = static::createClient();
$client->request('GET', '/blog/hello-world');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Hello world');
}
}
Because services are injected, unit tests can replace a dependency with a stub or a mock without touching the container. Keep integration tests for the HTTP layer and unit tests for the services that hold business rules.
Best practices
- Prefer constructor injection over fetching services from the container.
- Keep controllers thin; move logic into services.
- Use repositories for queries and keep SQL out of controllers.
- Declare routes as attributes next to the action they call.
- Let Flex manage bundle configuration instead of writing boilerplate.
- Separate configuration by environment and keep secrets in environment variables.
- Put slow work on Messenger instead of in the request.
- Read the profiler when something is slow or surprising.
Common mistakes
- Calling
$container->get()instead of injecting dependencies. - Putting business logic in controllers and entities.
- Forgetting to register or bind an interface, then wondering what the container resolved.
- Committing
.envsecrets instead of keeping them out of version control. - Editing generated migrations after they have run.
- Mixing configuration styles randomly across YAML, XML and PHP.
- Ignoring deprecation notices until the next major upgrade.
Where to go next
Symfony gives you the components and the discipline to build systems that last, and its ideas are visible in frameworks across the ecosystem. Read the Laravel guide to see those components used for rapid application development, or compare the philosophy with the minimal Express approach. To design and evolve the APIs you expose, continue with REST and API versioning.