Back to symfony.com

The Symfony Quick Tour

All 11 lessons in a single page (~35 min read). Prefer a guided experience? Take the interactive tour.

1. Your first page

Creating a Symfony application takes one command and a few seconds. You can use Composer or the Symfony CLI utility.

In Symfony, a page is a method of a plain PHP class called a controller. No mandatory base class, no code generation, no XML. This is the entire code of a working page.

The #[Route] attribute defines the URL of the page, right next to the code that handles it. When you visit /lucky/number, Symfony runs this method.

The method's job is to return a Response object with the contents of the page. HTML, JSON, a file download: everything is a response.

$ composer create-project symfony/skeleton my_project$ cd my_project/$ symfony server:start# [OK] Web server listening# https://127.0.0.1:8000
src/Controller/LuckyController.php
<?phpnamespace App\Controller;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;class LuckyController{    #[Route('/lucky/number')]    public function number(): Response    {        $number = random_int(0, 100);        return new Response("<h1>Lucky number: $number</h1>");    }}

2. Routes

Routes live next to the code they run, defined as PHP attributes. No separate routing files to keep in sync (though YAML config is also available if you prefer it).

Wrap any part of the URL in curly braces to make it dynamic. The matched value is passed to the method as an argument with the same name.

Add requirements to placeholders with regular expressions. Symfony also converts the value to the type of the argument: this $year is already an integer.

Routes have names, so you never hardcode URLs anywhere else: templates and controllers generate them with path('blog_show', {slug: 'symfony-8'}). Change the URL once, and it changes everywhere.

src/Controller/BlogController.php
<?phpnamespace App\Controller;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;class BlogController{    #[Route('/blog', name: 'blog_list')]    public function list(): Response    {        // ...    }    #[Route('/blog/{slug}', name: 'blog_show')]    public function show(string $slug): Response    {        // ...    }    #[Route('/blog/archive/{year}', requirements: ['year' => '\d{4}'])]    public function archive(int $year): Response    {        // ...    }}

3. Controllers

Controllers usually extend AbstractController, an optional base class that adds shortcuts for the most common operations: rendering templates, returning JSON, redirecting, etc.

Need the incoming request? Type-hint a Request argument and Symfony passes it to you, with a clean object-oriented API for headers, cookies, query parameters and more.

Building an API? The json() helper serializes your data and sets the right response headers.

Or skip the boilerplate entirely: #[MapQueryParameter] maps a query string parameter (/api/search?query=symfony) straight into a typed method argument, with an optional default value.

src/Controller/ApiController.php
<?phpnamespace App\Controller;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;use Symfony\Component\Routing\Attribute\Route;class ApiController extends AbstractController{    #[Route('/api/status')]    public function status(Request $request): JsonResponse    {        return $this->json([            'status' => 'OK',            'ip' => $request->getClientIp(),        ]);    }    #[Route('/api/search')]    public function search(        #[MapQueryParameter] string $query = '',    ): JsonResponse {        return $this->json(['query' => $query]);    }}

4. Twig templates

The render() helper renders a template and wraps the result in a Response object. The second argument is the list of variables the template can use.

Templates are written in Twig, a template language designed to be concise, safe (output is escaped by default) and friendly for designers. {{ ... }} prints a value, and filters like |title transform it.

{% ... %} runs logic, like looping over the speakers array passed by the controller.

The true Twig superpower: inheritance. Write the shared elements (e.g. the header and footer) once in a base.html.twig template, and let other templates extend it and fill in only the content blocks that change on each page.

src/Controller/ConferenceController.php
<?phpnamespace App\Controller;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;class ConferenceController extends AbstractController{    #[Route('/conference/{slug}')]    public function show(string $slug): Response    {        return $this->render('conference/show.html.twig', [            'conference' => $slug,            'speakers' => ['Fabien', 'Nicolas', 'Javier'],        ]);    }}
templates/conference/show.html.twig
{% extends 'base.html.twig' %}{% block title %}{{ conference|title }} Conference{% endblock %}{% block body %}    <h1>Welcome to {{ conference|title }}!</h1>    <ul>        {% for speaker in speakers %}            <li>{{ speaker }}</li>        {% endfor %}    </ul>{% endblock %}

5. Flex recipes

Your new Symfony project is tiny on purpose: no mailer, no ORM, no templates until you decide you need them. When you do, install the feature with Composer.

Here is the magic: Symfony Flex, a Composer plugin, applies the package's recipe automatically. It creates the configuration files, registers the bundle and adds the environment variables the package needs. Zero manual setup.

Secrets and machine-specific values live in environment variables, following the twelve-factor app philosophy. The recipe already added a sensible default to your .env file. And don't worry: this file only stores harmless development defaults. In production, you define real environment variables or use Symfony's encrypted secrets vault.

This works for hundreds of packages: try composer require orm, composer require security or composer require debug. Un-installing is just as clean: composer remove mailer reverts all the changes made by the recipe.

$ composer require symfony/mailerSymfony operations: 1 recipe (executing)  - Configuring symfony/mailer (>=7.3): From github.com/symfony/recipes    Created "config/packages/mailer.yaml"    Added environment variable defaults "MAILER_DSN"    Registered the bundle in "config/bundles.php"
.env
APP_ENV=devAPP_SECRET=ThisIsNotASecret###> symfony/mailer ###MAILER_DSN=smtp://localhost###< symfony/mailer ###

6. Services & autowiring

Your app's logic lives in services: plain PHP classes like this one. There's nothing to register or configure: create the class and it's ready to use anywhere.

To use a service, type-hint it in the constructor. This is autowiring: Symfony reads the type and injects the right object. This design (dependency injection) keeps your classes decoupled and easy to test.

It works the same for the hundreds of services provided by Symfony and its ecosystem: here, a PSR-3 logger, ready to use by adding one constructor argument.

src/Service/GreetingGenerator.php
<?phpnamespace App\Service;class GreetingGenerator{    public function getRandomGreeting(string $name): string    {        $greeting = ['Hey', 'Yo', 'Aloha'][random_int(0, 2)];        return "$greeting $name!";    }}
src/Controller/GreetingController.php
<?phpnamespace App\Controller;use App\Service\GreetingGenerator;use Psr\Log\LoggerInterface;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;class GreetingController{    public function __construct(        private GreetingGenerator $greetingGenerator,        private LoggerInterface $logger,    ) {    }    #[Route('/greet/{name}')]    public function greet(string $name): Response    {        $this->logger->info("Greeting $name");        $greeting = $this->greetingGenerator->getRandomGreeting($name);        return new Response($greeting);    }}

7. The profiler

Symfony's debugging experience is one of its superpowers. Install it with one command.

Every page now shows a debug toolbar at the bottom: request time, memory, database queries, logs, deprecations... Click any value to open the full profiler, which keeps a detailed trace of every request, even redirects and API calls.

And forget var_dump() + die(): the dump() function sends any variable, beautifully formatted, to the toolbar without breaking the page.

$ composer require profiler
src/Controller/CheckoutController.php
<?phpnamespace App\Controller;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;class CheckoutController extends AbstractController{    #[Route('/checkout')]    public function checkout(): Response    {        $cart = ['items' => 3, 'total' => 41.5];        // inspect it in the toolbar, no var_dump() + die() needed        dump($cart);        return $this->render('checkout/index.html.twig', [            'cart' => $cart,        ]);    }}

8. Database & Doctrine

Symfony integrates Doctrine, the most powerful PHP ORM. Each database table maps to an entity: a plain PHP class where attributes describe the columns.

You don't even write these classes by hand: the interactive make:entity command generates and updates them, and the migration commands create the SQL to evolve your database schema safely.

To query the database, autowire the entity's repository. The built-in methods cover the common cases, and you add your own methods for complex queries.

src/Entity/Article.php
<?phpnamespace App\Entity;use App\Repository\ArticleRepository;use Doctrine\ORM\Mapping as ORM;#[ORM\Entity(repositoryClass: ArticleRepository::class)]class Article{    #[ORM\Id]    #[ORM\GeneratedValue]    #[ORM\Column]    private ?int $id = null;    #[ORM\Column(length: 255)]    private ?string $title = null;    #[ORM\Column(type: 'text')]    private ?string $content = null;    // ... getters and setters}
src/Controller/ArticleController.php
<?phpnamespace App\Controller;use App\Repository\ArticleRepository;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Attribute\Route;class ArticleController extends AbstractController{    #[Route('/articles')]    public function list(ArticleRepository $articles): Response    {        $latest = $articles->findBy([], ['title' => 'ASC'], limit: 10);        return $this->render('article/list.html.twig', [            'articles' => $latest,        ]);    }}
$ php bin/console make:entity Article$ php bin/console make:migration$ php bin/console doctrine:migrations:migrate

9. Forms & validation

Validation rules are declared where your data lives, as attributes. Symfony ships with dozens of constraints: emails, URLs, ranges, IBANs, image dimensions... and you can create your own.

Now the fun part: #[MapRequestPayload] takes the JSON body of the request, deserializes it into your object and validates it. One attribute replaces all that boilerplate.

If validation fails, Symfony automatically returns a 422 response detailing every error. If it succeeds, your controller receives a perfectly typed object.

Building HTML forms instead of APIs? The Symfony Form component renders, processes and validates them reusing these same constraints, including protection against CSRF attacks.

src/Dto/SignupRequest.php
<?phpnamespace App\Dto;use Symfony\Component\Validator\Constraints as Assert;class SignupRequest{    public function __construct(        #[Assert\NotBlank]        #[Assert\Email]        public string $email,        #[Assert\Length(min: 8)]        public string $password,    ) {    }}
src/Controller/SignupController.php
<?phpnamespace App\Controller;use App\Dto\SignupRequest;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;use Symfony\Component\Routing\Attribute\Route;class SignupController extends AbstractController{    #[Route('/api/signup', methods: ['POST'])]    public function signup(        #[MapRequestPayload] SignupRequest $request,    ): JsonResponse {        // if you get here, the JSON payload was valid; otherwise,        // Symfony already returned a 422 response with the errors        return $this->json(['welcome' => $request->email]);    }}

10. The console

Every Symfony app includes a console. Writing your own commands takes one class: the #[AsCommand] attribute registers it and gives it a name.

Arguments and options are declared as method parameters with attributes, and SymfonyStyle gives you beautiful output, tables, progress bars and interactive questions for free.

The console also comes packed with built-in commands: inspect routes and services, clear caches, run migrations... and the make:* commands, which generate controllers, entities, forms, tests and more, so you never start from a blank file.

src/Command/SendNewsletterCommand.php
<?phpnamespace App\Command;use Symfony\Component\Console\Attribute\Argument;use Symfony\Component\Console\Attribute\AsCommand;use Symfony\Component\Console\Style\SymfonyStyle;#[AsCommand(    name: 'app:send-newsletter',    description: 'Sends the weekly newsletter',)]class SendNewsletterCommand{    public function __invoke(        SymfonyStyle $io,        #[Argument] string $segment = 'all',    ): int {        $io->title("Sending the newsletter to the '$segment' segment");        // ... your business logic        $io->success('Newsletter sent!');        return 0;    }}
$ php bin/console app:send-newsletter premium$ php bin/console make:controller$ php bin/console debug:router

11. Where to go next

That's it! You've seen the essentials: pages, routes, controllers, templates, services, the database, validation and the console. This is how Symfony feels every day: explicit, typed code with the framework doing the repetitive work for you.

Ready to build something real? Install the Symfony CLI and create a new Symfony app: micro or full-featured.

Your next steps: the Advanced Tour below shows some of the most impressive Symfony features in small doses. When you want depth, Symfony: The Fast Track (the free official book) builds a complete project step by step, and the documentation covers everything else.

And you won't walk alone: Symfony has one of the largest communities in PHP, with thousands of contributors, conferences around the world and many places to ask for help.

# run this if you are building a traditional web application$ symfony new my_project --webapp# run this if you are building a microservice, console app or API$ symfony new my_project