Знакомство с Symfony: компоненты, kernel, controllers
Symfony: финальная остановка PHP-трека
Ты дошёл до места, где открывается весь современный PHP. Symfony - это не один фреймворк, а 50+ независимых пакетов, на которых построены Laravel, Drupal, Magento, и тысячи продуктовых компаний. Знание Symfony - это знание индустрии PHP.
Что такое Symfony
Symfony одновременно - три разные вещи:
- Набор компонентов (
symfony/http-foundation,symfony/console,symfony/cache, ...). Каждый можно поставить отдельно через Composer. Laravel под капотом использует ~10 этих компонентов. - Полный фреймворк (
symfony/framework-bundle) - все компоненты + DI + конфиг + autoconfig. Когда говорят «Symfony» - обычно имеют в виду это. - Экосистема: 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
Каждый 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"}. Что произошло:
#[Route]атрибут зарегистрировал маршрут (автоматически -App\:вservices.yaml)- Symfony распарсил
{name}из URL и подставил в аргумент 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:
- Нашёл
UserControllerчерезApp\: resource: '../src/*'вservices.yaml - Прочитал сигнатуру конструктора через рефлексию
- Для
UserRepositoryнашёл класс вApp\Repository\UserRepository - Для
LoggerInterfaceнашёл реализациюMonolog\Logger(черезMonologBundle) - Собрал контейнер при первом запросе, закешировал в
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 Docs | symfony.com/doc/current/ (главное место) |
| Quick Tour | symfony.com/doc/current/quick_tour.html |
| API Platform | api-platform.com - REST/GraphQL за минуты |
| SymfonyCasts | symfonycasts.com - видеокурсы автора фреймворка |
| Symfony Internals | github.com/symfony/symfony - читать исходники |
| The Architecture of OSS | aosabook.org - глава о Symfony |
Что выбрать: Symfony vs Laravel
Оба - топ-фреймворки PHP. Кратко:
| Критерий | Symfony | Laravel |
|---|---|---|
| Стиль | Корпоративный, явный, конфиг | Скрипт-style, магия, конвенция |
| DI | Полноценный контейнер с проверкой типов | Service Container с фасадами |
| ORM | Doctrine (DataMapper) | Eloquent (ActiveRecord) |
| Кривая обучения | Круче, но «как индустриальный код» | Положе, быстрый старт |
| Где доминирует | Enterprise, банки, e-commerce, EU | Стартапы, API, MVP, US |
| Где переплетается | Laravel внутри использует ~10 Symfony-компонентов | - |
Если ты учишь PHP в 2026-м с прицелом на серьёзную работу - начинай с Symfony. Laravel позже подхватишь за неделю; в обратную сторону переход дороже, потому что в Symfony много концепций (DI, EventDispatcher, Messenger), которые в Laravel-стиле теряются.
Типичные ошибки начинающих в Symfony
- «Положу в
services.yamlвсё руками». Сделай нет - autowiring +App\: resource:- это базовый паттерн. Руками регистрируй только сервисы с особыми аргументами. - Использовать
$this->getDoctrine()в контроллере. Метод устарел; инжектируйEntityManagerInterfaceили соответствующий*Repositoryчерез конструктор. - Не запускать
cache:clearпосле правки конфигов. В dev -auto_warm, но если что-то «не подхватилось»,php bin/console cache:clearрешает 80% проблем. - Логика в Twig-шаблонах. Twig - рендеринг, не вычисления. Готовь данные в контроллере.
- Доступ к 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.