Знакомство с Symfony: компоненты, kernel, controllers

Symfony: финальная остановка PHP-трека

Ты дошёл до места, где открывается весь современный PHP. Symfony - это не один фреймворк, а 50+ независимых пакетов, на которых построены Laravel, Drupal, Magento, и тысячи продуктовых компаний. Знание Symfony - это знание индустрии PHP.

Что такое Symfony

Symfony одновременно - три разные вещи:

  1. Набор компонентов (symfony/http-foundation, symfony/console, symfony/cache, ...). Каждый можно поставить отдельно через Composer. Laravel под капотом использует ~10 этих компонентов.
  2. Полный фреймворк (symfony/framework-bundle) - все компоненты + DI + конфиг + autoconfig. Когда говорят «Symfony» - обычно имеют в виду это.
  3. Экосистема: API Platform (быстрое REST/GraphQL), EasyAdmin (админки), Sylius (e-commerce), Mautic (маркетинг). Всё на одном фундаменте.

Создание проекта

composer create-project symfony/skeleton:^7.0 myapi
cd myapi
composer require api          # API-пак: orm, validator, security, doctrine
symfony serve                 # локальный сервер с HTTPS (если установлен Symfony CLI)

Что в коробке:

myapi/
├── bin/console               # CLI: миграции, сервисы, отладка
├── config/
│   ├── bundles.php           # подключённые бандлы
│   ├── packages/             # YAML-конфиг каждого пакета
│   ├── routes/               # маршруты
│   └── services.yaml         # DI-конфиг
├── public/index.php          # front controller
├── src/
│   ├── Controller/
│   ├── Entity/               # Doctrine ORM сущности
│   ├── Repository/
│   └── Kernel.php
├── templates/                # Twig
├── var/                      # cache, logs (не коммитим)
├── vendor/                   # composer
├── .env                      # окружение (НЕ коммитим .env.local)
└── composer.json

HttpKernel: сердце Symfony

Жизненный цикл запроса в Symfony Kernel

Каждый HTTP-запрос идёт через Kernel:

Request (PSR-7 / HttpFoundation)
   │
   ▼
[kernel.request listeners]      → security, routing, CORS
   │
   ▼
[controller resolver]            → найти контроллер по маршруту
   │
   ▼
[kernel.controller listeners]    → передать DI зависимости, аргументы
   │
   ▼
🎯 Controller::action()           → твой код
   │
   ▼
[kernel.view listeners]          → если вернули не Response (напр. array → JsonResponse)
   │
   ▼
[kernel.response listeners]      → добавить заголовки, sessions, cookies
   │
   ▼
Response

Между шагами - события через EventDispatcher. Любой кусок можно зацепить своим listener'ом или middleware. Это и есть «всё расширяемо».

Контроллер: минимальный пример

<?php
// src/Controller/HelloController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

final class HelloController extends AbstractController
{
    #[Route('/hello/{name}', methods: ['GET'])]
    public function index(string $name): JsonResponse
    {
        return $this->json(['hello' => $name]);
    }
}

Запускаешь - GET /hello/world отдаёт {"hello":"world"}. Что произошло:

  1. #[Route] атрибут зарегистрировал маршрут (автоматически - App\: в services.yaml)
  2. Symfony распарсил {name} из URL и подставил в аргумент
  3. AbstractController::json() сформировал JsonResponse

DI и autowiring

Из урока 18 про DI знаешь, что такое контейнер. В Symfony он работает почти всегда автоматически:

<?php
namespace App\Controller;

use App\Repository\UserRepository;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

final class UserController extends AbstractController
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly LoggerInterface $log,
    ) {}

    #[Route('/users/{id<\d+>}', methods: ['GET'])]
    public function show(int $id): JsonResponse
    {
        $user = $this->users->find($id);
        if ($user === null) {
            throw $this->createNotFoundException();
        }
        $this->log->info('show user', ['id' => $id]);
        return $this->json($user);
    }
}

UserRepository и LoggerInterface тебе никто не передавал в __construct руками. Symfony:

  1. Нашёл UserController через App\: resource: '../src/*' в services.yaml
  2. Прочитал сигнатуру конструктора через рефлексию
  3. Для UserRepository нашёл класс в App\Repository\UserRepository
  4. Для LoggerInterface нашёл реализацию Monolog\Logger (через MonologBundle)
  5. Собрал контейнер при первом запросе, закешировал в var/cache/

Это autowiring. Подсунул новую зависимость в конструктор - заработало сразу.

Doctrine ORM: работа с БД

composer require orm-pack
composer require --dev orm-fixtures

.env:

DATABASE_URL="postgresql://app:pass@localhost:5432/mydb?serverVersion=16"

Создание сущности через maker:

php bin/console make:entity User
# вопросы: имена полей, типы, nullable

Получится примерно так:

<?php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use App\Repository\UserRepository;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180, unique: true)]
    private string $email;

    #[ORM\Column]
    private string $passwordHash;

    // геттеры/сеттеры
}

Миграции:

php bin/console make:migration
php bin/console doctrine:migrations:migrate

Использование:

<?php
use Doctrine\ORM\EntityManagerInterface;

public function create(EntityManagerInterface $em, string $email): void
{
    $user = new User();
    $user->setEmail($email);
    $user->setPasswordHash(password_hash('secret', PASSWORD_DEFAULT));

    $em->persist($user);
    $em->flush();   // SQL: INSERT INTO users ...
}

Doctrine берёт на себя SQL и mapping - ты работаешь с объектами.

Валидация через атрибуты

<?php
use Symfony\Component\Validator\Constraints as Assert;

final class CreateUserRequest
{
    #[Assert\Email]
    public string $email;

    #[Assert\NotBlank]
    #[Assert\Length(min: 8)]
    public string $password;

    #[Assert\GreaterThanOrEqual(18)]
    public int $age;
}
<?php
#[Route('/users', methods: ['POST'])]
public function create(
    #[MapRequestPayload] CreateUserRequest $req,
): JsonResponse {
    // если валидация упала → 422 автоматически с массивом violations
    // если дошли сюда - $req валиден
    return $this->json(['ok' => true]);
}

MapRequestPayload (Symfony 6.3+) десериализует JSON-тело в DTO + валидирует - то, что мы с тобой писали вручную в уроке 21 про REST, тут в одну строку.

Security Component

# config/packages/security.yaml
security:
  password_hashers:
    App\Entity\User: 'auto'

  providers:
    app_user_provider:
      entity: { class: App\Entity\User, property: email }

  firewalls:
    api:
      pattern: ^/api
      stateless: true
      provider: app_user_provider
      json_login:
        check_path: /api/login
        username_path: email
        password_path: password
        success_handler: lexik_jwt_authentication.handler.authentication_success
        failure_handler: lexik_jwt_authentication.handler.authentication_failure

  access_control:
 - { path: ^/api/login, roles: PUBLIC_ACCESS }
 - { path: ^/api,        roles: ROLE_USER }

С плагином lexik/jwt-authentication-bundle это - полноценный JWT-аутентификатор. POST /api/login с {email, password} → ответ с JWT. Дальше клиент шлёт Authorization: Bearer <jwt> и Security сам проверяет.

В контроллере достать пользователя:

<?php
use Symfony\Component\Security\Http\Attribute\CurrentUser;

#[Route('/api/me')]
public function me(#[CurrentUser] User $user): JsonResponse
{
    return $this->json(['email' => $user->getEmail()]);
}

Messenger: очереди и асинхронность

composer require messenger

Команда (DTO):

<?php
namespace App\Message;

final readonly class SendWelcomeEmail
{
    public function __construct(public int $userId) {}
}

Обработчик:

<?php
namespace App\MessageHandler;

use App\Message\SendWelcomeEmail;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
    public function __invoke(SendWelcomeEmail $message): void
    {
        // отправка реального email
    }
}

Отправка из контроллера:

<?php
use Symfony\Component\Messenger\MessageBusInterface;

public function register(MessageBusInterface $bus, int $userId): void
{
    $bus->dispatch(new SendWelcomeEmail($userId));
}

Воркер:

php bin/console messenger:consume async --time-limit=3600

Что внутри - Redis/Rabbit/Amazon SQS/Doctrine - настраивается в messenger.yaml. Бизнес-код от транспорта не зависит.

Console: CLI-команды

<?php
namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:greet', description: 'Приветствует пользователя')]
final class GreetCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $io->success('Hello, world!');
        return Command::SUCCESS;
    }
}
php bin/console app:greet

Один и тот же Kernel, тот же DI-контейнер - просто другая точка входа. Кронам, миграциям, импортам, утилитам тут самое место.

Бандлы и конфиг

Бандл - это пакет с настройками, DI-конфигом и сервисами. Подключение:

composer require symfony/twig-bundle
# Symfony Flex автоматически добавит в config/bundles.php и создаст config/packages/twig.yaml

config/packages/*.yaml группируется по бандлу. Можно делать профили на окружение:

# config/packages/cache.yaml
framework:
  cache:
    app: cache.adapter.filesystem

when@prod:
  framework:
    cache:
      app: cache.adapter.redis
      default_redis_provider: '%env(REDIS_URL)%'

Один файл, разные стратегии для dev/prod/test.

Тесты

PHPUnit + symfony/test-pack:

composer require --dev test
<?php
namespace App\Tests;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class UserControllerTest extends WebTestCase
{
    public function testList(): void
    {
        $client = static::createClient();
        $client->request('GET', '/users');

        self::assertResponseIsSuccessful();
        self::assertJson($client->getResponse()->getContent());
    }
}

createClient() поднимает Kernel в тестовом окружении (БД с фикстурами, in-memory сервисы). Это функциональные тесты - почти E2E, но без сети.

Куда копать дальше

ТемаИсточник
Symfony Docssymfony.com/doc/current/ (главное место)
Quick Toursymfony.com/doc/current/quick_tour.html
API Platformapi-platform.com - REST/GraphQL за минуты
SymfonyCastssymfonycasts.com - видеокурсы автора фреймворка
Symfony Internalsgithub.com/symfony/symfony - читать исходники
The Architecture of OSSaosabook.org - глава о Symfony

Что выбрать: Symfony vs Laravel

Оба - топ-фреймворки PHP. Кратко:

КритерийSymfonyLaravel
СтильКорпоративный, явный, конфигСкрипт-style, магия, конвенция
DIПолноценный контейнер с проверкой типовService Container с фасадами
ORMDoctrine (DataMapper)Eloquent (ActiveRecord)
Кривая обученияКруче, но «как индустриальный код»Положе, быстрый старт
Где доминируетEnterprise, банки, e-commerce, EUСтартапы, API, MVP, US
Где переплетаетсяLaravel внутри использует ~10 Symfony-компонентов-

Если ты учишь PHP в 2026-м с прицелом на серьёзную работу - начинай с Symfony. Laravel позже подхватишь за неделю; в обратную сторону переход дороже, потому что в Symfony много концепций (DI, EventDispatcher, Messenger), которые в Laravel-стиле теряются.

Типичные ошибки начинающих в Symfony

  1. «Положу в services.yaml всё руками». Сделай нет - autowiring + App\: resource: - это базовый паттерн. Руками регистрируй только сервисы с особыми аргументами.
  2. Использовать $this->getDoctrine() в контроллере. Метод устарел; инжектируй EntityManagerInterface или соответствующий *Repository через конструктор.
  3. Не запускать cache:clear после правки конфигов. В dev - auto_warm, но если что-то «не подхватилось», php bin/console cache:clear решает 80% проблем.
  4. Логика в Twig-шаблонах. Twig - рендеринг, не вычисления. Готовь данные в контроллере.
  5. Доступ к request через $_GET/$_POST. В Symfony - Request $request в аргументах или DTO через MapRequestPayload.

Best practices

  • Доверяй autowiring + App\: resource: - ручная регистрация в services.yaml нужна только для сервисов с нестандартными аргументами (переменные окружения, именованные сервисы).
  • Инжектируй конкретный репозиторий или EntityManagerInterface через конструктор, не через устаревший $this->getDoctrine().
  • Используй #[MapRequestPayload] + Validator Constraints для валидации входящих DTO - вместо ручного парсинга тела и написания своего валидатора.
  • После правки конфигов запускай php bin/console cache:clear - большинство «странных» симптомов (не видит сервис, не работает маршрут) лечатся очисткой кеша.
  • Доступ к данным запроса - через Request $request или #[MapRequestPayload], не через суперглобалы $_GET/$_POST - фреймворк предоставляет типизированный и тестируемый API.

Финальное мини-задание (capstone)

Собери небольшой Symfony-проект:

  • composer create-project symfony/skeleton, composer require api
  • Создай сущность Task(id, title, status, createdAt) через make:entity
  • Запусти миграцию: make:migration + doctrine:migrations:migrate
  • Контроллеры: GET /tasks, POST /tasks с MapRequestPayload + валидацией, GET /tasks/{id} с 404
  • Подключи Security: make:user, make:auth с JWT (lexik-bundle)
  • Защити POST /tasks через IsGranted('ROLE_USER')
  • Команда app:tasks:cleanup - удаляет таски старше 30 дней
  • Сообщение TaskCreated через Messenger, обработчик пишет лог
  • Функциональный тест на GET /tasks (status 200, JSON)

Доделал - поздравляю, ты прошёл PHP-трек до production-grade.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.