Mid📖Теория5 min

Основы контроллеров

AbstractController, Request/Response, JSON ответы, редиректы, streaming, HTTP exceptions

Основы контроллеров

Контроллер в Symfony — это callable, который принимает HTTP-запрос и возвращает HTTP-ответ. На экзамене проверяют знание AbstractController, всех типов Response и best practices.

Способы объявления контроллеров

1. Наследование от AbstractController (рекомендуется)

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class ProductController extends AbstractController
{
    #[Route('/products', name: 'product_list')]
    public function list(): Response
    {
        return $this->render('product/list.html.twig');
    }
}

2. POPO контроллер с #[AsController]

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
class ApiProductController
{
    public function __construct(
        private readonly ProductRepository $repository,
    ) {
    }

    #[Route('/api/products', name: 'api_product_list')]
    public function list(): JsonResponse
    {
        return new JsonResponse($this->repository->findAll());
    }
}

3. Invokable контроллер (single action)

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;

#[AsController]
#[Route('/health', name: 'health_check')]
class HealthCheckController
{
    public function __invoke(): Response
    {
        return new Response('OK', Response::HTTP_OK);
    }
}

Подвох экзамена: Без #[AsController] или наследования от AbstractController обычный класс НЕ получит возможность инжектить сервисы в action-методы. #[AsController] помечает класс тегом controller.service_arguments, что позволяет Symfony инжектить сервисы в параметры action-методов (не только в конструктор).

Методы AbstractController

Rendering

<?php

declare(strict_types=1);

class PageController extends AbstractController
{
    public function show(): Response
    {
        // render() — renders Twig template, returns Response
        return $this->render('page/show.html.twig', [
            'title' => 'My Page',
            'items' => [1, 2, 3],
        ]);

        // renderView() — returns rendered HTML as string (NOT Response)
        $html = $this->renderView('email/welcome.html.twig', [
            'user' => $user,
        ]);
        // Use when you need HTML string (e.g., for email body)
    }
}

JSON ответы

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

class ApiController extends AbstractController
{
    public function data(): Response
    {
        // json() — uses Symfony Serializer, supports groups
        return $this->json(
            data: ['id' => 1, 'name' => 'Widget'],
            status: Response::HTTP_OK,
            headers: ['X-Custom' => 'value'],
            context: ['groups' => ['api:read']],
        );

        // Direct JsonResponse (no Serializer, just json_encode)
        return new JsonResponse(
            data: ['status' => 'ok'],
            status: 200,
        );

        // From raw JSON string
        return JsonResponse::fromJsonString('{"id": 1}');
    }
}

Подвох экзамена: $this->json() использует Symfony Serializer (если установлен) и поддерживает serialization groups через context. new JsonResponse() использует простой json_encode() без Serializer. Для сложных объектов (entities) нужен $this->json() с groups.

Редиректы

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\Response;

class RedirectController extends AbstractController
{
    public function oldPage(): Response
    {
        // Redirect by route name (HTTP 302)
        return $this->redirectToRoute('product_list');

        // With route parameters
        return $this->redirectToRoute('product_show', ['id' => 42]);

        // Permanent redirect (HTTP 301)
        return $this->redirectToRoute('product_list', [], Response::HTTP_MOVED_PERMANENTLY);

        // Redirect after POST (HTTP 303 See Other)
        return $this->redirectToRoute('product_list', [], Response::HTTP_SEE_OTHER);

        // Redirect to absolute URL
        return $this->redirect('https://example.com');
    }
}

Forward (внутренняя переадресация)

<?php

declare(strict_types=1);

class LegacyController extends AbstractController
{
    public function oldEndpoint(): Response
    {
        // Forward creates an internal sub-request
        // URL in browser does NOT change
        return $this->forward(NewController::class . '::newAction', [
            'id' => 42,
        ]);
    }
}

Подвох экзамена: forward() создаёт sub-request внутри Symfony — URL в браузере НЕ меняется, клиент не знает о переадресации. redirect() / redirectToRoute() отправляет HTTP 302/301, браузер делает НОВЫЙ запрос по другому URL.

Файлы и потоки

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;

class DownloadController extends AbstractController
{
    // Sending file to client
    public function download(): BinaryFileResponse
    {
        // Display inline (in browser)
        return $this->file('/path/to/file.pdf');

        // Force download with custom name
        return $this->file(
            '/path/to/file.pdf',
            'report.pdf',
            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
        );
    }

    // Streaming large data (memory efficient)
    public function exportCsv(): StreamedResponse
    {
        $response = new StreamedResponse(function (): void {
            $handle = fopen('php://output', 'w');
            fputcsv($handle, ['ID', 'Name', 'Email']);

            foreach ($this->getUsers() as $user) {
                fputcsv($handle, [$user->getId(), $user->getName(), $user->getEmail()]);
                flush();
            }

            fclose($handle);
        });

        $response->headers->set('Content-Type', 'text/csv');
        $response->headers->set('Content-Disposition', 'attachment; filename="users.csv"');

        return $response;
    }
}

Объект Request

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\Request;

class SearchController extends AbstractController
{
    #[Route('/search', name: 'search')]
    public function search(Request $request): Response
    {
        // Query string (?q=symfony&page=2)
        $query = $request->query->get('q', '');
        $page = $request->query->getInt('page', 1);
        $active = $request->query->getBoolean('active', true);

        // POST data
        $name = $request->request->get('name');

        // JSON body
        $data = $request->toArray();

        // Headers (case-insensitive)
        $contentType = $request->headers->get('Content-Type');
        $auth = $request->headers->get('Authorization');

        // Request metadata
        $method = $request->getMethod();           // GET, POST, etc.
        $path = $request->getPathInfo();           // /search
        $host = $request->getHost();               // example.com
        $isSecure = $request->isSecure();          // true/false
        $isAjax = $request->isXmlHttpRequest();    // true/false
        $clientIp = $request->getClientIp();       // 192.168.1.1

        // Route parameters (internal bag)
        $routeName = $request->attributes->get('_route');

        return $this->json(['query' => $query, 'page' => $page]);
    }
}

Property Bags таблица

Свойство Источник PHP-эквивалент Тип Bag
$request->query URL query string $_GET InputBag
$request->request POST body $_POST InputBag
$request->cookies Cookies $_COOKIE InputBag
$request->files Uploaded files $_FILES FileBag
$request->server Server vars $_SERVER ServerBag
$request->headers HTTP headers Из $_SERVER HeaderBag
$request->attributes Route params, internal Нет аналога ParameterBag

Подвох экзамена: $request->attributes — НЕ HTTP-данные. Это внутренний bag для передачи данных между компонентами Symfony (route parameters, resolved objects). Route parameters доступны через $request->attributes, а НЕ через $request->query.

HTTP Exceptions

<?php

declare(strict_types=1);

use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class ProductController extends AbstractController
{
    #[Route('/products/{id}', name: 'product_show')]
    public function show(int $id): Response
    {
        $product = $this->findProduct($id);

        if (!$product) {
            // Helper creates exception, throw sends it
            throw $this->createNotFoundException('Product not found');
        }

        return $this->json($product);
    }

    public function restricted(): Response
    {
        // 403 Forbidden
        throw $this->createAccessDeniedException('No access');

        // 400 Bad Request
        throw new BadRequestHttpException('Invalid input');

        // Any HTTP status code
        throw new \Symfony\Component\HttpKernel\Exception\HttpException(429, 'Too many requests');
    }
}

Подвох экзамена: createNotFoundException() и createAccessDeniedException() только СОЗДАЮТ объект исключения, но НЕ бросают его. Обязательно нужен throw. Написать $this->createNotFoundException() без throw — ничего не произойдёт, код продолжит выполняться.

Полная таблица методов AbstractController

Метод Возвращает Описание
render() Response Рендер Twig-шаблона
renderView() string Рендер шаблона как HTML-строка
json() JsonResponse Сериализация в JSON (через Serializer)
redirect() RedirectResponse Редирект по URL
redirectToRoute() RedirectResponse Редирект по имени маршрута
forward() Response Внутренний sub-request
file() BinaryFileResponse Отправка файла
generateUrl() string Генерация URL по имени маршрута
addFlash() void Flash-сообщение в сессию
isGranted() bool Проверка прав доступа
denyAccessUnlessGranted() void 403 если нет доступа
getUser() ?UserInterface Текущий аутентифицированный пользователь
getParameter() mixed Параметр DI-контейнера
createNotFoundException() NotFoundHttpException Создание 404 exception
createAccessDeniedException() AccessDeniedException Создание 403 exception
isCsrfTokenValid() bool Проверка CSRF-токена
createForm() FormInterface Создание формы
createFormBuilder() FormBuilderInterface Form builder

Проверь себя

🧪

Чем `forward()` отличается от `redirectToRoute()`?

🧪

Чем `$this->json()` отличается от `new JsonResponse()`?

🧪

Какой property bag содержит route parameters в Symfony Request?

🧪

Что произойдёт при вызове `$this->createNotFoundException('Not found')` БЕЗ `throw`?

🧪

Обязан ли контроллер в Symfony наследовать AbstractController?