Питання для співбесіди Symfony: Топ 25 у 2026
25 найчастіших питань зі співбесід Symfony. Архітектура, Doctrine ORM, сервіси, безпека, форми й тести з детальними відповідями та прикладами коду.

Співбесіди з Symfony перевіряють володіння еталонним професійним PHP-фреймворком, розуміння компонентно-орієнтованої архітектури, ORM Doctrine та здатність створювати надійні й масштабовані застосунки. Цей посібник охоплює 25 найчастіших питань — від основ Symfony до складних виробничих патернів.
Рекрутери цінують кандидатів, які розуміють філософію Symfony: розв'язання через сервіси, явну конфігурацію та дотримання стандартів PSR. Уміння пояснити архітектурні рішення фреймворку є вирішальним.
Основи Symfony
Питання 1: Поясніть життєвий цикл запиту в Symfony
Життєвий цикл запиту Symfony проходить через HTTP Kernel і використовує систему подій, що дає змогу розширювати кожен крок. Це розуміння необхідне для налагодження та налаштування поведінки застосунку.
// Точка входу для всіх HTTP-запитів
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
// Symfony Runtime обробляє bootstrap
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};// Kernel оркеструє обробку запиту
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
// Kernel завантажує bundle і налаштовує контейнер
// Ключові події життєвого циклу:
// 1. kernel.request - перед маршрутизацією
// 2. kernel.controller - після резолюції контролера
// 3. kernel.view - якщо контролер не повертає Response
// 4. kernel.response - перед відправкою відповіді
// 5. kernel.terminate - після відправки (асинхронні задачі)
}Повний цикл: index.php → Runtime → Kernel → HttpKernel → EventDispatcher (kernel.request) → Router → Controller → EventDispatcher (kernel.response) → Response. Кожен крок можна перехопити через Event Subscribers.
Питання 2: Що таке Service Container і Dependency Injection у Symfony?
Service Container (або DIC, Dependency Injection Container) — серце Symfony. Він керує створенням, конфігурацією та інжекцією усіх сервісів застосунку.
// Сервіс із автоматично інжектованими залежностями
namespace App\Service;
use App\Repository\OrderRepository;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class PaymentService
{
public function __construct(
private readonly HttpClientInterface $stripeClient, // Налаштований HTTP-клієнт
private readonly OrderRepository $orderRepository, // Репозиторій Doctrine
private readonly LoggerInterface $logger, // Логер PSR-3
private readonly string $stripeApiKey, // Інжектований параметр
) {}
public function processPayment(int $orderId, float $amount): bool
{
$order = $this->orderRepository->find($orderId);
try {
$response = $this->stripeClient->request('POST', '/charges', [
'body' => [
'amount' => $amount * 100,
'currency' => 'eur',
'source' => $order->getPaymentToken(),
],
]);
$order->markAsPaid($response->toArray()['id']);
$this->orderRepository->save($order, true);
return true;
} catch (\Exception $e) {
$this->logger->error('Payment failed', [
'order' => $orderId,
'error' => $e->getMessage(),
]);
return false;
}
}
}# config/services.yaml
# Конфігурація сервісів
services:
_defaults:
autowire: true # Автоматична інжекція через type-hint
autoconfigure: true # Автоматична конфігурація тегів
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# Явна конфігурація з параметрами
App\Service\PaymentService:
arguments:
$stripeClient: '@stripe.client'
$stripeApiKey: '%env(STRIPE_API_KEY)%'Autowiring автоматично резолвить залежності за type-hint. Скалярні параметри потребують явної конфігурації.
Питання 3: У чому різниця між Bundle і компонентом Symfony?
Bundle — це багаторазові пакунки, що інтегрують функціонал у застосунок Symfony. Компоненти — самостійні PHP-бібліотеки, які можна використовувати без Symfony.
// Структура власного Bundle
namespace App\MyBundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class MyBundle extends AbstractBundle
{
// Завантажує конфігурацію bundle
public function loadExtension(
array $config,
ContainerConfigurator $container,
ContainerBuilder $builder
): void {
// Завантажує сервіси bundle
$container->import('../config/services.yaml');
// Умовна конфігурація
if ($config['feature_enabled']) {
$container->services()
->set('my_bundle.feature_service', FeatureService::class)
->autowire();
}
}
// Конфігурація bundle за замовчуванням
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->booleanNode('feature_enabled')->defaultTrue()->end()
->scalarNode('api_key')->isRequired()->end()
->end();
}
}// Використання компонента без Symfony
// Компоненти — самостійні PHP-бібліотеки
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
// Можна використовувати в будь-якому PHP-проєкті
$request = Request::createFromGlobals();
$response = new Response('Hello World', 200);
$response->send();Bundle інкапсулюють конфігурацію, сервіси та ресурси. Компоненти — низькорівневі інструменти, що використовуються будь-де.
Питання 4: Як працюють Event Subscribers у Symfony?
Event Subscribers дозволяють реагувати на події фреймворку чи застосунку, відокремлюючи бізнес-логіку від основного коду.
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
class ApiExceptionSubscriber implements EventSubscriberInterface
{
// Декларує події підписки та їхні пріоритети
public static function getSubscribedEvents(): array
{
return [
// Високий пріоритет (виконується перед іншими)
KernelEvents::EXCEPTION => ['onKernelException', 100],
KernelEvents::RESPONSE => ['onKernelResponse', 0],
];
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
$request = $event->getRequest();
// Обробляє лише API-запити
if (!str_starts_with($request->getPathInfo(), '/api')) {
return;
}
$statusCode = $exception instanceof HttpExceptionInterface
? $exception->getStatusCode()
: 500;
$response = new JsonResponse([
'error' => true,
'message' => $exception->getMessage(),
'code' => $statusCode,
], $statusCode);
// Замінює відповідь нашим JSON
$event->setResponse($response);
}
public function onKernelResponse(\Symfony\Component\HttpKernel\Event\ResponseEvent $event): void
{
// Додає власні заголовки
$event->getResponse()->headers->set('X-Api-Version', '1.0');
}
}Event Subscribers виявляються автоматично завдяки autoconfigure. Пріоритет визначає порядок виконання (вищий = виконується першим).
Doctrine ORM
Питання 5: Поясніть зв'язки Doctrine та їхні відмінності
Doctrine пропонує кілька типів зв'язків для моделювання асоціацій між сутностями. Кожен тип впливає на запити та продуктивність.
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// Зв'язок OneToOne: користувач має один профіль
#[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
private ?Profile $profile = null;
// Зв'язок OneToMany: користувач має декілька статей
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Article::class, orphanRemoval: true)]
private Collection $articles;
// Зв'язок ManyToMany: декілька користувачів мають декілька ролей
#[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')]
#[ORM\JoinTable(name: 'user_roles')]
private Collection $roles;
public function __construct()
{
// Обов'язкова ініціалізація колекції
$this->articles = new ArrayCollection();
$this->roles = new ArrayCollection();
}
public function addArticle(Article $article): static
{
if (!$this->articles->contains($article)) {
$this->articles->add($article);
$article->setAuthor($this); // Двостороння синхронізація
}
return $this;
}
public function removeArticle(Article $article): static
{
if ($this->articles->removeElement($article)) {
if ($article->getAuthor() === $this) {
$article->setAuthor(null);
}
}
return $this;
}
}#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
// Зв'язок ManyToOne: декілька статей належать одному автору
#[ORM\ManyToOne(inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
// ManyToMany з додатковими атрибутами через pivot-сутність
#[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticleTag::class)]
private Collection $articleTags;
}Двосторонні зв'язки потребують ручної синхронізації. Сторона "owning" (з JoinColumn/JoinTable) керує персистентністю.
Питання 6: Що таке проблема N+1 і як її розв'язати з Doctrine?
Проблема N+1 виникає, коли основний запит породжує N додаткових запитів для завантаження зв'язків. Це найчастіша причина повільності в застосунках Symfony.
namespace App\Repository;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
// ПОГАНО: N+1 запитів при доступі до авторів
public function findAllBad(): array
{
return $this->findAll();
// + 1 запит на статтю для завантаження автора
}
// ДОБРЕ: JOIN із eager fetch
public function findAllWithAuthor(): array
{
return $this->createQueryBuilder('a')
->addSelect('u') // SELECT також автора
->leftJoin('a.author', 'u') // JOIN за зв'язком
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
// ДОБРЕ: декілька JOIN для декількох зв'язків
public function findAllWithDetails(): array
{
return $this->createQueryBuilder('a')
->addSelect('u', 'c', 't') // SELECT усіх зв'язків
->leftJoin('a.author', 'u')
->leftJoin('a.comments', 'c')
->leftJoin('a.tags', 't')
->where('a.status = :status')
->setParameter('status', 'published')
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
// ДОБРЕ: пакетне завантаження для великих списків
public function findAllOptimized(): array
{
$query = $this->createQueryBuilder('a')
->getQuery();
// Завантажує зв'язки пакетами по 100
$query->setFetchMode(Article::class, 'author', ClassMetadata::FETCH_BATCH);
return $query->getResult();
}
}Symfony Profiler з панеллю Doctrine допомагає виявити проблеми N+1. Кількість запитів з'являється у Web Debug Toolbar.
Питання 7: Як створити Query Extensions і фільтри Doctrine?
Query Extensions і фільтри Doctrine дають змогу автоматично застосовувати умови до всіх запитів — ідеально для multi-tenancy чи soft delete.
// Розширення API Platform для автоматичної фільтрації за користувачем
namespace App\Doctrine\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Article;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private readonly Security $security,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = []
): void {
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
// Застосовується лише до Article
if ($resourceClass !== Article::class) {
return;
}
// Адмін бачить усе
if ($this->security->isGranted('ROLE_ADMIN')) {
return;
}
$user = $this->security->getUser();
$rootAlias = $queryBuilder->getRootAliases()[0];
// Автоматичний фільтр за автором
$queryBuilder
->andWhere(sprintf('%s.author = :current_user', $rootAlias))
->setParameter('current_user', $user);
}
}// Глобальний фільтр Doctrine для виключення видалених елементів
namespace App\Doctrine\Filter;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
class SoftDeleteFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
// Перевіряє наявність поля deletedAt у сутності
if (!$targetEntity->hasField('deletedAt')) {
return '';
}
return sprintf('%s.deleted_at IS NULL', $targetTableAlias);
}
}# config/packages/doctrine.yaml
doctrine:
orm:
filters:
soft_delete:
class: App\Doctrine\Filter\SoftDeleteFilter
enabled: trueФільтри застосовуються на рівні SQL, extensions — на рівні QueryBuilder. Тимчасово вимикаються через $em->getFilters()->disable('soft_delete').
Готовий до співбесід з Symfony?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Безпека Symfony
Питання 8: Як працює система безпеки Symfony?
Компонент Security Symfony керує автентифікацією (хто є користувач) і авторизацією (що йому дозволено) через розширювану архітектуру.
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api
stateless: true
jwt: ~ # Lexik JWT Bundle
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
access_control:
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: ROLE_USER }
- { path: ^/admin, roles: ROLE_ADMIN }// Власний authenticator для специфічної логіки
namespace App\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
// Цей authenticator обробляє лише запити з X-API-KEY
return $request->headers->has('X-API-KEY');
}
public function authenticate(Request $request): Passport
{
$apiKey = $request->headers->get('X-API-KEY');
if (null === $apiKey) {
throw new AuthenticationException('No API key provided');
}
// UserBadge завантажує користувача за ідентифікатором
return new SelfValidatingPassport(
new UserBadge($apiKey, function (string $apiKey) {
// Логіка завантаження користувача за API-ключем
return $this->userRepository->findByApiKey($apiKey);
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// null = продовжити запит у звичайному режимі
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse([
'error' => $exception->getMessage(),
], Response::HTTP_UNAUTHORIZED);
}
}Архітектура безпеки спирається на Firewalls (конфігурація), Authenticators (автентифікація) та Voters (авторизація).
Питання 9: Як реалізувати Voters для гранульованої авторизації?
Voters дозволяють писати складну та повторно використовувану логіку авторизації, відокремлюючи бізнес-правила від коду контролера.
namespace App\Security\Voter;
use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
public const VIEW = 'ARTICLE_VIEW';
public const EDIT = 'ARTICLE_EDIT';
public const DELETE = 'ARTICLE_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
// Цей voter обробляє лише Article та ці атрибути
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
&& $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
$article = $subject;
// Опубліковані статті видимі всім
if ($attribute === self::VIEW && $article->isPublished()) {
return true;
}
// Інші дії потребують автентифікованого користувача
if (!$user instanceof User) {
return false;
}
return match ($attribute) {
self::VIEW => $this->canView($article, $user),
self::EDIT => $this->canEdit($article, $user),
self::DELETE => $this->canDelete($article, $user),
default => false,
};
}
private function canView(Article $article, User $user): bool
{
// Чернетки видимі лише автору або адмінам
return $article->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
private function canEdit(Article $article, User $user): bool
{
// Лише автор може редагувати
return $article->getAuthor() === $user;
}
private function canDelete(Article $article, User $user): bool
{
// Автор або адмін можуть видалити
return $article->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
}// Використання Voter у контролері
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ArticleController extends AbstractController
{
#[Route('/articles/{id}/edit', name: 'article_edit')]
#[IsGranted(ArticleVoter::EDIT, subject: 'article')]
public function edit(Article $article): Response
{
// Авторизація перевіряється автоматично
// 403, якщо voter відмовляє у доступі
}
// Програмна альтернатива
#[Route('/articles/{id}', name: 'article_show')]
public function show(Article $article): Response
{
$this->denyAccessUnlessGranted(ArticleVoter::VIEW, $article);
// Або з умовою
if (!$this->isGranted(ArticleVoter::EDIT, $article)) {
// Сховати кнопку редагування
}
}
}{# У Twig #}
{% if is_granted('ARTICLE_EDIT', article) %}
<a href="{{ path('article_edit', {id: article.id}) }}">Редагувати</a>
{% endif %}Voters виявляються автоматично і викликаються під час isGranted(). Стандартна стратегія дає доступ, якщо хоча б один Voter голосує позитивно.
Питання 10: Як захистити API за допомогою JWT у Symfony?
Автентифікація JWT (JSON Web Token) — стандартне рішення для stateless API. Symfony зазвичай використовує LexikJWTAuthenticationBundle.
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'
token_ttl: 3600 # 1 годинаnamespace App\Controller\Api;
use App\Entity\User;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api')]
class AuthController extends AbstractController
{
#[Route('/login', name: 'api_login', methods: ['POST'])]
public function login(
Request $request,
UserPasswordHasherInterface $passwordHasher,
JWTTokenManagerInterface $jwtManager,
): JsonResponse {
$data = json_decode($request->getContent(), true);
$user = $this->userRepository->findOneBy(['email' => $data['email']]);
if (!$user || !$passwordHasher->isPasswordValid($user, $data['password'])) {
return new JsonResponse(['error' => 'Invalid credentials'], 401);
}
// Генеруємо токен JWT
$token = $jwtManager->create($user);
return new JsonResponse([
'token' => $token,
'user' => [
'id' => $user->getId(),
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
],
]);
}
#[Route('/refresh-token', name: 'api_refresh_token', methods: ['POST'])]
public function refreshToken(): JsonResponse
{
// Автоматично обробляється bundle, якщо налаштовано
// Повертає новий токен з refresh-токена
}
}// Кастомізація payload JWT
namespace App\EventListener;
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
class JWTCreatedListener
{
public function onJWTCreated(JWTCreatedEvent $event): void
{
$user = $event->getUser();
$payload = $event->getData();
// Додає власні дані до токена
$payload['user_id'] = $user->getId();
$payload['email'] = $user->getEmail();
$payload['permissions'] = $user->getPermissions();
$event->setData($payload);
}
}Токен JWT надсилається у заголовку Authorization: Bearer <token>. Bundle автоматично перевіряє підпис і термін дії.
Форми Symfony
Питання 11: Як створювати складні форми з валідацією?
Компонент Form Symfony генерує HTML-форми, обробляє надсилання та валідує дані з обмеженнями (constraints).
namespace App\Form;
use App\Entity\Article;
use App\Entity\Category;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Заголовок статті',
'attr' => ['placeholder' => 'Введіть заголовок...'],
'constraints' => [
new Assert\NotBlank(message: 'Заголовок обовʼязковий'),
new Assert\Length(
min: 10,
max: 255,
minMessage: 'Заголовок має містити щонайменше {{ limit }} символів',
),
],
])
->add('content', TextareaType::class, [
'label' => 'Зміст',
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 100),
],
])
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'placeholder' => 'Оберіть категорію',
'query_builder' => function ($repo) {
return $repo->createQueryBuilder('c')
->where('c.active = true')
->orderBy('c.name', 'ASC');
},
])
->add('coverImage', FileType::class, [
'label' => 'Обкладинка',
'mapped' => false, // Не привʼязано до сутності
'required' => false,
'constraints' => [
new Assert\Image(
maxSize: '5M',
mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
mimeTypesMessage: 'Непідтримуваний формат зображення',
),
],
])
->add('publishedAt', DateTimeType::class, [
'widget' => 'single_text',
'required' => false,
'label' => 'Дата публікації',
]);
// Слухач події для динамічної модифікації форми
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$article = $event->getData();
$form = $event->getForm();
// Додає поле лише при редагуванні
if ($article && $article->getId()) {
$form->add('slug', TextType::class, [
'disabled' => true,
'help' => 'Slug неможливо змінити',
]);
}
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
'validation_groups' => ['Default', 'article_creation'],
]);
}
}#[Route('/articles/new', name: 'article_new')]
public function new(Request $request, SluggerInterface $slugger): Response
{
$article = new Article();
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Обробка завантаження файлу
$coverImage = $form->get('coverImage')->getData();
if ($coverImage) {
$filename = $slugger->slug($article->getTitle()).'-'.uniqid().'.'.$coverImage->guessExtension();
$coverImage->move($this->getParameter('covers_directory'), $filename);
$article->setCoverImageFilename($filename);
}
$this->entityManager->persist($article);
$this->entityManager->flush();
$this->addFlash('success', 'Статтю успішно створено!');
return $this->redirectToRoute('article_show', ['id' => $article->getId()]);
}
return $this->render('article/new.html.twig', [
'form' => $form,
]);
}FormEvents (PRE_SET_DATA, POST_SUBMIT тощо) дозволяють динамічно змінювати поля залежно від контексту.
Питання 12: Як реалізувати власну валідацію з constraints?
Symfony дозволяє створювати власні валідаційні constraints для складних бізнес-правил.
// Власний constraint
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UniqueEmail extends Constraint
{
public string $message = 'Email "{{ value }}" вже використовується.';
public ?int $excludeId = null; // Для виключення поточного користувача при оновленні
}// Validator, повʼязаний із constraint
namespace App\Validator;
use App\Repository\UserRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class UniqueEmailValidator extends ConstraintValidator
{
public function __construct(
private readonly UserRepository $userRepository,
) {}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof UniqueEmail) {
throw new UnexpectedTypeException($constraint, UniqueEmail::class);
}
if (null === $value || '' === $value) {
return; // NotBlank обробляє порожні значення
}
$existingUser = $this->userRepository->findOneBy(['email' => $value]);
// Перевіряє існування користувача з таким email
// і що це не поточний користувач (при оновленні)
if ($existingUser && $existingUser->getId() !== $constraint->excludeId) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}
}// Використання constraint у сутності
use App\Validator as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
class User
{
#[Assert\NotBlank]
#[Assert\Email]
#[AppAssert\UniqueEmail]
private ?string $email = null;
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
#[Assert\Regex(
pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/',
message: 'Пароль має містити велику, малу літери та цифру'
)]
private ?string $plainPassword = null;
}// Constraint на рівні класу для багатопольної валідації
// src/Validator/PasswordMatch.php
#[\Attribute(\Attribute::TARGET_CLASS)]
class PasswordMatch extends Constraint
{
public string $message = 'Паролі не збігаються.';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}Власні constraints виявляються автоматично. Суфікс Validator обовʼязковий для validator-а.
Messenger та асинхронна комунікація
Питання 13: Як реалізувати асинхронну обробку з Messenger?
Symfony Messenger надсилає повідомлення в черги для асинхронної обробки, покращуючи відгук застосунку.
// Повідомлення (DTO з даними)
namespace App\Message;
final class SendWelcomeEmail
{
public function __construct(
public readonly int $userId,
public readonly string $locale = 'en',
) {}
}// Обробник, що опрацьовує повідомлення
namespace App\MessageHandler;
use App\Message\SendWelcomeEmail;
use App\Repository\UserRepository;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly MailerInterface $mailer,
) {}
public function __invoke(SendWelcomeEmail $message): void
{
$user = $this->userRepository->find($message->userId);
if (!$user) {
return; // Користувача видалено за цей час
}
$email = (new TemplatedEmail())
->to($user->getEmail())
->subject('Ласкаво просимо на нашу платформу!')
->htmlTemplate('emails/welcome.html.twig')
->context([
'user' => $user,
'locale' => $message->locale,
]);
$this->mailer->send($email);
}
}# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
# Маршрутизація повідомлень до async-транспорту
App\Message\SendWelcomeEmail: async
App\Message\ProcessImage: async
App\Message\GenerateReport: async// Відправлення повідомлення
use Symfony\Component\Messenger\MessageBusInterface;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register', methods: ['POST'])]
public function register(
Request $request,
MessageBusInterface $bus,
): Response {
// ... створення користувача
// Асинхронна відправка - email буде надіслано у фоні
$bus->dispatch(new SendWelcomeEmail(
userId: $user->getId(),
locale: $request->getLocale(),
));
// Негайна відповідь користувачеві
return $this->redirectToRoute('app_login');
}
}Worker запускається командою php bin/console messenger:consume async -vv. У продакшені для підтримки worker-а використовують Supervisor.
Питання 14: Як обробляти помилки та повторні спроби з Messenger?
Messenger пропонує надійні механізми обробки збоїв: автоматичні повторні спроби, dead letter queue та ручна обробка невдалих повідомлень.
namespace App\MessageHandler;
use App\Exception\PaymentFailedException;
use App\Exception\PaymentRetryableException;
use App\Message\ProcessPayment;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
#[AsMessageHandler]
final class ProcessPaymentHandler
{
public function __invoke(ProcessPayment $message): void
{
try {
$this->paymentGateway->process($message->orderId);
} catch (PaymentRetryableException $e) {
// Тимчасова помилка (timeout, rate limit) → повтор
throw new RecoverableMessageHandlingException(
$e->getMessage(),
previous: $e
);
} catch (PaymentFailedException $e) {
// Постійна помилка (недійсна картка) → без повторів
throw new UnrecoverableMessageHandlingException(
$e->getMessage(),
previous: $e
);
}
}
}// Конфігурація повторів на рівні повідомлення
namespace App\Message;
use Symfony\Component\Messenger\Stamp\DelayStamp;
final class ProcessPayment
{
public function __construct(
public readonly int $orderId,
public readonly int $attempt = 1,
) {}
// Власна затримка повтору залежно від спроби
public function getRetryDelay(): int
{
return match ($this->attempt) {
1 => 5000, // 5 секунд
2 => 30000, // 30 секунд
3 => 300000, // 5 хвилин
default => 600000,
};
}
}# Команди керування невдалими повідомленнями
php bin/console messenger:failed:show # Показати невдалі повідомлення
php bin/console messenger:failed:retry # Повторити всі повідомлення
php bin/console messenger:failed:retry 123 # Повторити конкретне повідомлення
php bin/console messenger:failed:remove 123 # Видалити повідомленняСтратегія повторів і транспорт "failed" гарантують, що жодне повідомлення не буде втрачено. Повідомлення можна аналізувати та повторювати вручну.
Готовий до співбесід з Symfony?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Тести в Symfony
Питання 15: Як структурувати тести в Symfony?
Symfony надає PHPUnit з виділеними хелперами для тестування різних шарів застосунку: модульні, функціональні та інтеграційні тести.
// Модульний тест: тестує ізольований клас
namespace App\Tests\Unit\Service;
use App\Service\PriceCalculator;
use PHPUnit\Framework\TestCase;
class PriceCalculatorTest extends TestCase
{
private PriceCalculator $calculator;
protected function setUp(): void
{
$this->calculator = new PriceCalculator();
}
public function testCalculateTotalWithoutDiscount(): void
{
$total = $this->calculator->calculateTotal(100.00, 0);
$this->assertEquals(100.00, $total);
}
public function testCalculateTotalWithPercentageDiscount(): void
{
$total = $this->calculator->calculateTotal(100.00, 20);
$this->assertEquals(80.00, $total);
}
/**
* @dataProvider discountProvider
*/
public function testCalculateTotalWithVariousDiscounts(
float $price,
int $discount,
float $expected
): void {
$total = $this->calculator->calculateTotal($price, $discount);
$this->assertEquals($expected, $total);
}
public static function discountProvider(): array
{
return [
'no discount' => [100.00, 0, 100.00],
'10% discount' => [100.00, 10, 90.00],
'50% discount' => [200.00, 50, 100.00],
'max discount' => [100.00, 100, 0.00],
];
}
}// Функціональний тест: тестує контролери через HTTP
namespace App\Tests\Functional\Controller;
use App\Entity\Article;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ArticleControllerTest extends WebTestCase
{
private $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = static::createClient();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
}
public function testListArticlesReturnsOk(): void
{
$this->client->request('GET', '/articles');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('h1');
}
public function testCreateArticleRequiresAuthentication(): void
{
$this->client->request('GET', '/articles/new');
$this->assertResponseRedirects('/login');
}
public function testAuthenticatedUserCanCreateArticle(): void
{
// Автентифікація
$user = $this->createUser();
$this->client->loginUser($user);
// Доступ до форми
$crawler = $this->client->request('GET', '/articles/new');
$this->assertResponseIsSuccessful();
// Надсилання форми
$form = $crawler->selectButton('Створити')->form([
'article[title]' => 'Test Article Title',
'article[content]' => 'Це зміст моєї тестової статті з достатньою кількістю символів.',
]);
$this->client->submit($form);
// Перевірка
$this->assertResponseRedirects();
$this->client->followRedirect();
$this->assertSelectorTextContains('h1', 'Test Article Title');
// Перевірка в базі даних
$article = $this->entityManager->getRepository(Article::class)
->findOneBy(['title' => 'Test Article Title']);
$this->assertNotNull($article);
}
private function createUser(): User
{
$user = new User();
$user->setEmail('test@example.com');
$user->setPassword('$2y$13$hashedpassword');
$this->entityManager->persist($user);
$this->entityManager->flush();
return $user;
}
protected function tearDown(): void
{
// Очищення тестової бази даних
$this->entityManager->getConnection()->executeStatement('DELETE FROM article');
$this->entityManager->getConnection()->executeStatement('DELETE FROM user');
parent::tearDown();
}
}Доцільно розділяти модульні тести (без kernel), функціональні (з kernel) та інтеграційні (з реальними сервісами).
Питання 16: Як використовувати fixtures і DatabaseResetter?
Fixtures заповнюють базу даних реалістичними тестовими даними. Компонент DoctrineTestBundle полегшує скидання між тестами.
namespace App\DataFixtures;
use App\Entity\Article;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
class ArticleFixtures extends Fixture implements DependentFixtureInterface
{
public function load(ObjectManager $manager): void
{
for ($i = 1; $i <= 20; $i++) {
$article = new Article();
$article->setTitle("Test Article Number $i");
$article->setSlug("test-article-$i");
$article->setContent("Детальний зміст статті номер $i...");
$article->setStatus($i <= 15 ? 'published' : 'draft');
$article->setPublishedAt($i <= 15 ? new \DateTimeImmutable("-$i days") : null);
// Посилання на користувача, створеного UserFixtures
$article->setAuthor($this->getReference('user-'.($i % 3), User::class));
$manager->persist($article);
// Створює посилання для інших fixtures
$this->addReference("article-$i", $article);
}
$manager->flush();
}
public function getDependencies(): array
{
// UserFixtures має завантажуватися перед ArticleFixtures
return [
UserFixtures::class,
];
}
}namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserFixtures extends Fixture implements FixtureGroupInterface
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
public function load(ObjectManager $manager): void
{
$users = [
['email' => 'admin@example.com', 'roles' => ['ROLE_ADMIN'], 'ref' => 'user-0'],
['email' => 'author@example.com', 'roles' => ['ROLE_AUTHOR'], 'ref' => 'user-1'],
['email' => 'user@example.com', 'roles' => ['ROLE_USER'], 'ref' => 'user-2'],
];
foreach ($users as $userData) {
$user = new User();
$user->setEmail($userData['email']);
$user->setRoles($userData['roles']);
$user->setPassword($this->passwordHasher->hashPassword($user, 'password123'));
$manager->persist($user);
$this->addReference($userData['ref'], $user);
}
$manager->flush();
}
public static function getGroups(): array
{
return ['test', 'dev'];
}
}// Використання DAMADoctrineTestBundle для автоматичного скидання
namespace App\Tests\Functional;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ArticleControllerTest extends WebTestCase
{
use \DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver;
public function testPublishedArticlesCount(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$count = $em->getRepository(Article::class)
->count(['status' => 'published']);
$this->assertEquals(15, $count); // Згідно з fixtures
}
}# Завантаження fixtures
php bin/console doctrine:fixtures:load
php bin/console doctrine:fixtures:load --group=test
php bin/console doctrine:fixtures:load --env=testDAMADoctrineTestBundle обгортає кожен тест у транзакцію, яка відкочується, що позбавляє від необхідності перезавантажувати fixtures між тестами.
Архітектура та просунуті патерни
Питання 17: Як реалізувати CQRS з Symfony?
CQRS (Command Query Responsibility Segregation) розділяє операції читання і запису, дозволяючи їх незалежно оптимізувати.
// Command: представляє намір модифікації
namespace App\Message\Command;
final class CreateArticleCommand
{
public function __construct(
public readonly string $title,
public readonly string $content,
public readonly int $authorId,
public readonly array $tagIds = [],
) {}
}// CommandHandler: виконує модифікацію
namespace App\MessageHandler\Command;
use App\Entity\Article;
use App\Message\Command\CreateArticleCommand;
use App\Repository\TagRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\String\Slugger\SluggerInterface;
#[AsMessageHandler]
final class CreateArticleCommandHandler
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly TagRepository $tagRepository,
private readonly SluggerInterface $slugger,
) {}
public function __invoke(CreateArticleCommand $command): Article
{
$author = $this->userRepository->find($command->authorId)
?? throw new \InvalidArgumentException('Author not found');
$article = new Article();
$article->setTitle($command->title);
$article->setSlug($this->slugger->slug($command->title)->lower());
$article->setContent($command->content);
$article->setAuthor($author);
foreach ($command->tagIds as $tagId) {
$tag = $this->tagRepository->find($tagId);
if ($tag) {
$article->addTag($tag);
}
}
$this->entityManager->persist($article);
$this->entityManager->flush();
return $article;
}
}// Query: представляє запит на читання
namespace App\Message\Query;
final class GetArticleBySlugQuery
{
public function __construct(
public readonly string $slug,
public readonly bool $withComments = false,
) {}
}// QueryHandler: отримує дані (може використовувати оптимізовану read model)
namespace App\MessageHandler\Query;
use App\DTO\ArticleDTO;
use App\Message\Query\GetArticleBySlugQuery;
use App\Repository\ArticleRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class GetArticleBySlugQueryHandler
{
public function __construct(
private readonly ArticleRepository $repository,
) {}
public function __invoke(GetArticleBySlugQuery $query): ?ArticleDTO
{
$qb = $this->repository->createQueryBuilder('a')
->select('a', 'u')
->leftJoin('a.author', 'u')
->where('a.slug = :slug')
->setParameter('slug', $query->slug);
if ($query->withComments) {
$qb->addSelect('c')
->leftJoin('a.comments', 'c');
}
$article = $qb->getQuery()->getOneOrNullResult();
return $article ? ArticleDTO::fromEntity($article) : null;
}
}// Використання Command і Query Bus
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
class ArticleController extends AbstractController
{
public function __construct(
private readonly MessageBusInterface $commandBus,
private readonly MessageBusInterface $queryBus,
) {}
#[Route('/articles', methods: ['POST'])]
public function create(Request $request): Response
{
$data = json_decode($request->getContent(), true);
$envelope = $this->commandBus->dispatch(new CreateArticleCommand(
title: $data['title'],
content: $data['content'],
authorId: $this->getUser()->getId(),
));
$article = $envelope->last(HandledStamp::class)->getResult();
return $this->json($article, 201);
}
#[Route('/articles/{slug}', methods: ['GET'])]
public function show(string $slug): Response
{
$envelope = $this->queryBus->dispatch(new GetArticleBySlugQuery(
slug: $slug,
withComments: true,
));
$article = $envelope->last(HandledStamp::class)->getResult();
return $this->json($article);
}
}CQRS дозволяє оптимізовувати читання (кешування, проєкції) та запис (валідація, події) окремо.
Питання 18: Як правильно реалізувати Repository Pattern у Symfony?
Repository Pattern уже присутній у Symfony через Doctrine, але його можна збагатити інтерфейсами та бізнес-методами.
// Інтерфейс для розв'язання залежностей і тестування
namespace App\Repository\Contract;
use App\Entity\Article;
use Doctrine\Common\Collections\Collection;
interface ArticleRepositoryInterface
{
public function find(int $id): ?Article;
public function findBySlug(string $slug): ?Article;
public function findPublished(int $limit = 20, int $offset = 0): array;
public function findByAuthor(int $authorId): array;
public function save(Article $article, bool $flush = false): void;
public function remove(Article $article, bool $flush = false): void;
}// Реалізація repository через Doctrine
namespace App\Repository;
use App\Entity\Article;
use App\Repository\Contract\ArticleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ArticleRepository extends ServiceEntityRepository implements ArticleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Article::class);
}
public function findBySlug(string $slug): ?Article
{
return $this->createQueryBuilder('a')
->addSelect('u', 't')
->leftJoin('a.author', 'u')
->leftJoin('a.tags', 't')
->where('a.slug = :slug')
->setParameter('slug', $slug)
->getQuery()
->getOneOrNullResult();
}
public function findPublished(int $limit = 20, int $offset = 0): array
{
return $this->createQueryBuilder('a')
->addSelect('u')
->leftJoin('a.author', 'u')
->where('a.status = :status')
->setParameter('status', 'published')
->orderBy('a.publishedAt', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset)
->getQuery()
->getResult();
}
public function findByAuthor(int $authorId): array
{
return $this->createQueryBuilder('a')
->where('a.author = :authorId')
->setParameter('authorId', $authorId)
->orderBy('a.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function save(Article $article, bool $flush = false): void
{
$this->getEntityManager()->persist($article);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(Article $article, bool $flush = false): void
{
$this->getEntityManager()->remove($article);
if ($flush) {
$this->getEntityManager()->flush();
}
}
// Методи складних запитів
public function findPopularByCategory(int $categoryId, int $limit = 5): array
{
return $this->createQueryBuilder('a')
->addSelect('u')
->leftJoin('a.author', 'u')
->where('a.category = :categoryId')
->andWhere('a.status = :status')
->setParameter('categoryId', $categoryId)
->setParameter('status', 'published')
->orderBy('a.viewCount', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
}# config/services.yaml
# Привʼязка інтерфейсу до реалізації
services:
App\Repository\Contract\ArticleRepositoryInterface:
alias: App\Repository\ArticleRepositoryІнтерфейс дозволяє створювати тестові реалізації (InMemoryArticleRepository) або змінювати джерело даних, не змінюючи бізнес-код.
Питання 19: Як керувати конфігурацією та середовищами в Symfony?
Symfony використовує гнучку систему конфігурації з підтримкою змінних середовища, secrets і YAML-файлів для кожного середовища.
# config/packages/framework.yaml
# Конфігурація за замовчуванням
framework:
secret: '%env(APP_SECRET)%'
http_method_override: false
handle_all_throwables: true
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
php_errors:
log: true# config/packages/prod/framework.yaml
# Override для продакшену
framework:
session:
handler_id: '%env(REDIS_URL)%'
cookie_secure: true
when@prod:
framework:
router:
strict_requirements: null// Керування чутливими (зашифрованими) secrets
// php bin/console secrets:set DATABASE_URL --env=prod
// php bin/console secrets:list --reveal --env=prod// Кастомна конфігурація для bundle
namespace App\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('my_app');
$treeBuilder->getRootNode()
->children()
->scalarNode('api_key')
->isRequired()
->cannotBeEmpty()
->end()
->integerNode('cache_ttl')
->defaultValue(3600)
->min(0)
->end()
->arrayNode('features')
->addDefaultsIfNotSet()
->children()
->booleanNode('dark_mode')->defaultTrue()->end()
->booleanNode('beta_features')->defaultFalse()->end()
->end()
->end()
->end();
return $treeBuilder;
}
}// Інжекція конфігурації
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
class ConfigurableService
{
public function __construct(
#[Autowire('%env(API_KEY)%')]
private readonly string $apiKey,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
#[Autowire('%my_app.cache_ttl%')]
private readonly int $cacheTtl,
) {}
}Symfony secrets шифруються і версіонуються. Доцільно використовувати %env(...)% для змінних під час виконання, а параметри — для статичних значень.
Продуктивність і продакшн
Питання 20: Як оптимізувати продуктивність застосунку Symfony?
Оптимізація охоплює кілька рівнів: opcache, конфігурація, кеш застосунку та запити Doctrine.
# Оптимізована конфігурація Doctrine для продакшену
doctrine:
orm:
auto_generate_proxy_classes: false
metadata_cache_driver:
type: pool
pool: doctrine.system_cache_pool
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool# config/packages/cache.yaml
# Конфігурація кешу з Redis
framework:
cache:
app: cache.adapter.redis
system: cache.adapter.system
pools:
doctrine.result_cache_pool:
adapter: cache.adapter.redis
default_lifetime: 3600
doctrine.system_cache_pool:
adapter: cache.adapter.system
services:
Redis:
class: Redis
calls:
- connect:
- '%env(REDIS_HOST)%'
- '%env(int:REDIS_PORT)%'
Symfony\Component\Cache\Adapter\RedisAdapter:
arguments:
- '@Redis'// Використання result cache Doctrine
public function findPopularCached(): array
{
return $this->createQueryBuilder('a')
->addSelect('u')
->leftJoin('a.author', 'u')
->where('a.status = :status')
->setParameter('status', 'published')
->orderBy('a.viewCount', 'DESC')
->setMaxResults(10)
->getQuery()
->enableResultCache(3600, 'popular_articles') // Кеш 1 год
->getResult();
}# Команди оптимізації для продакшену
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
php bin/console doctrine:cache:clear-metadata --env=prod
php bin/console doctrine:cache:clear-query --env=prod
# Згенерувати proxy Doctrine
php bin/console doctrine:proxy:create-proxy-classes
# Скомпілювати оптимізований autoloader
composer install --no-dev --optimize-autoloader --classmap-authoritativeOPcache має бути увімкнений у продакшені з оптимальними налаштуваннями. Warmup створює кеш контейнера та router-а.
Питання 21: Як налаштувати логування та моніторинг у Symfony?
Структуроване логування і належний моніторинг необхідні для діагностики проблем у продакшені.
# config/packages/prod/monolog.yaml
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
buffer_size: 50
nested:
type: stream
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: debug
formatter: monolog.formatter.json
console:
type: console
process_psr_3_messages: false
channels: ['!event', '!doctrine']
slack:
type: slack
token: '%env(SLACK_TOKEN)%'
channel: '#alerts'
level: critical
bot_name: 'SymfonyBot'// Структуроване логування з контекстом
namespace App\Service;
use Psr\Log\LoggerInterface;
class PaymentService
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public function processPayment(Order $order): bool
{
$this->logger->info('Payment processing started', [
'order_id' => $order->getId(),
'amount' => $order->getTotal(),
'currency' => $order->getCurrency(),
'user_id' => $order->getUser()->getId(),
]);
try {
$result = $this->gateway->charge($order);
$this->logger->info('Payment successful', [
'order_id' => $order->getId(),
'transaction_id' => $result->getTransactionId(),
]);
return true;
} catch (\Exception $e) {
$this->logger->error('Payment failed', [
'order_id' => $order->getId(),
'error' => $e->getMessage(),
'exception' => $e,
]);
return false;
}
}
}// Логування HTTP-запитів
namespace App\EventSubscriber;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class RequestLoggerSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly LoggerInterface $logger,
) {}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::TERMINATE => 'onKernelTerminate',
];
}
public function onKernelTerminate(TerminateEvent $event): void
{
$request = $event->getRequest();
$response = $event->getResponse();
$this->logger->info('Request completed', [
'method' => $request->getMethod(),
'uri' => $request->getRequestUri(),
'status' => $response->getStatusCode(),
'duration_ms' => round((microtime(true) - $request->server->get('REQUEST_TIME_FLOAT')) * 1000),
'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
]);
}
}JSON-формат полегшує приймання даних у системах моніторингу (ELK, Datadog). Канали дозволяють фільтрувати за типом логу.
Питання 22: Як деплоїти Symfony-застосунок у продакшн?
Надійний деплой Symfony поєднує підготовку білду, безпечні міграції та атомарне перемикання.
#!/bin/bash
# deploy.sh - скрипт деплою
set -e
RELEASE_DIR="/var/www/releases/$(date +%Y%m%d%H%M%S)"
SHARED_DIR="/var/www/shared"
CURRENT_LINK="/var/www/current"
echo "Створення каталогу релізу..."
mkdir -p $RELEASE_DIR
echo "Клонування репозиторію..."
git clone --depth 1 --branch main git@github.com:org/repo.git $RELEASE_DIR
echo "Встановлення залежностей..."
cd $RELEASE_DIR
composer install --no-dev --optimize-autoloader --classmap-authoritative
echo "Зв'язування спільних файлів..."
ln -sf $SHARED_DIR/.env.local $RELEASE_DIR/.env.local
ln -sf $SHARED_DIR/var/log $RELEASE_DIR/var/log
ln -sf $SHARED_DIR/public/uploads $RELEASE_DIR/public/uploads
echo "Виконання міграцій..."
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
echo "Прогрів кешу..."
php bin/console cache:clear --env=prod --no-debug
php bin/console cache:warmup --env=prod --no-debug
echo "Налаштування прав..."
chown -R www-data:www-data $RELEASE_DIR
echo "Перемикання на новий реліз..."
ln -sfn $RELEASE_DIR $CURRENT_LINK
echo "Перезапуск PHP-FPM..."
sudo systemctl reload php8.3-fpm
echo "Перезапуск worker-ів Messenger..."
php bin/console messenger:stop-workers
echo "Очищення старих релізів..."
ls -dt /var/www/releases/*/ | tail -n +6 | xargs rm -rf
echo "Деплой завершено!"# .github/workflows/deploy.yml
# CI/CD-деплой через GitHub Actions
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: intl, pdo_pgsql, redis
- name: Install dependencies
run: composer install --no-dev --optimize-autoloader
- name: Run tests
run: php bin/phpunit
- name: Deploy to production
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/app
./deploy.shАтомарний деплой через symlink дозволяє миттєвий rollback. Worker-и Messenger потрібно перезапускати, щоб вони підхопили новий код.
API Platform і REST
Питання 23: Як побудувати REST API з API Platform?
API Platform — стандартне рішення для створення REST і GraphQL API із Symfony, що пропонує авто-документацію та HTTP-стандарти.
// Конфігурація ресурсу API Platform
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['article:list']],
),
new Get(
normalizationContext: ['groups' => ['article:read']],
),
new Post(
security: "is_granted('ROLE_AUTHOR')",
denormalizationContext: ['groups' => ['article:write']],
),
new Put(
security: "is_granted('ARTICLE_EDIT', object)",
),
new Patch(
security: "is_granted('ARTICLE_EDIT', object)",
),
new Delete(
security: "is_granted('ARTICLE_DELETE', object)",
),
],
order: ['publishedAt' => 'DESC'],
paginationItemsPerPage: 20,
)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['article:list', 'article:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 10, max: 255)]
#[Groups(['article:list', 'article:read', 'article:write'])]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
#[Groups(['article:read', 'article:write'])]
private ?string $content = null;
#[ORM\ManyToOne(inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['article:list', 'article:read'])]
private ?User $author = null;
#[ORM\Column(nullable: true)]
#[Groups(['article:list', 'article:read'])]
private ?\DateTimeImmutable $publishedAt = null;
}// Власний processor для бізнес-логіки
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\String\Slugger\SluggerInterface;
final class ArticleProcessor implements ProcessorInterface
{
public function __construct(
private readonly ProcessorInterface $persistProcessor,
private readonly Security $security,
private readonly SluggerInterface $slugger,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): Article {
if ($data instanceof Article && !$data->getId()) {
// Нова стаття: задати автора та slug
$data->setAuthor($this->security->getUser());
$data->setSlug($this->slugger->slug($data->getTitle())->lower());
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}# config/packages/api_platform.yaml
api_platform:
title: 'My API'
version: '1.0.0'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
docs_formats:
jsonld: ['application/ld+json']
jsonopenapi: ['application/vnd.openapi+json']
html: ['text/html']
defaults:
pagination_items_per_page: 20
pagination_client_items_per_page: true
pagination_maximum_items_per_page: 100
swagger:
versions: [3]API Platform автоматично генерує OpenAPI-документацію та надає фільтри, пагінацію і валідацію.
Питання 24: Як кастомізувати операції API Platform?
API Platform дозволяє створювати власні операції за допомогою контролерів або State Provider/Processor.
// Власні операції
#[ApiResource(
operations: [
// Стандартні операції
new GetCollection(),
new Get(),
// Власна операція з контролером
new Post(
uriTemplate: '/articles/{id}/publish',
controller: PublishArticleController::class,
openapi: new Model\Operation(
summary: 'Публікує статтю',
description: 'Змінює статус статті на "published"',
),
security: "is_granted('ARTICLE_EDIT', object)",
),
// Операція з власним State Provider
new GetCollection(
uriTemplate: '/articles/trending',
provider: TrendingArticlesProvider::class,
openapiContext: ['summary' => 'Популярні статті'],
),
],
)]
class Article
{
// ...
}// Контролер для власної операції
namespace App\Controller;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpKernel\Attribute\AsController;
#[AsController]
class PublishArticleController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
) {}
public function __invoke(Article $article): Article
{
if ($article->getStatus() === 'published') {
throw $this->createNotFoundException('Стаття вже опублікована');
}
$article->setStatus('published');
$article->setPublishedAt(new \DateTimeImmutable());
$this->entityManager->flush();
return $article;
}
}// State Provider для власної логіки читання
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\ArticleRepository;
final class TrendingArticlesProvider implements ProviderInterface
{
public function __construct(
private readonly ArticleRepository $repository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
// Власна логіка для трендових статей
return $this->repository->createQueryBuilder('a')
->where('a.status = :status')
->andWhere('a.publishedAt > :date')
->setParameter('status', 'published')
->setParameter('date', new \DateTimeImmutable('-7 days'))
->orderBy('a.viewCount', 'DESC')
->setMaxResults(10)
->getQuery()
->getResult();
}
}State Provider обробляють читання, State Processor — запис. Контролери залишаються доступними для складних випадків.
Питання 25: Як керувати міграціями бази даних у продакшені?
Міграції Doctrine мають бути спроєктовані так, щоб працювати без простою та забезпечувати простий rollback.
// Безпечна міграція: додавання nullable-колонки
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260202100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add role column to users table (nullable first)';
}
public function up(Schema $schema): void
{
// Крок 1: додати nullable-колонку
$this->addSql('ALTER TABLE users ADD role VARCHAR(50) DEFAULT NULL');
// Створити індекс CONCURRENTLY (PostgreSQL - неблокуюче)
$this->addSql('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_role ON users (role)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS idx_users_role');
$this->addSql('ALTER TABLE users DROP COLUMN role');
}
}// Міграція даних (окремо)
final class Version20260202100001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Populate role column with default value';
}
public function up(Schema $schema): void
{
// Пакетна міграція для великих таблиць
$this->addSql("UPDATE users SET role = 'ROLE_USER' WHERE role IS NULL");
}
public function down(Schema $schema): void
{
// Rollback для даних не потрібен
}
}// Фінальна міграція: зробити колонку NOT NULL
final class Version20260202100002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Make role column NOT NULL';
}
public function up(Schema $schema): void
{
// Попередня перевірка
$count = $this->connection->fetchOne('SELECT COUNT(*) FROM users WHERE role IS NULL');
if ($count > 0) {
throw new \RuntimeException("$count users still have NULL role");
}
$this->addSql('ALTER TABLE users ALTER COLUMN role SET NOT NULL');
$this->addSql("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'ROLE_USER'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users ALTER COLUMN role DROP NOT NULL');
$this->addSql('ALTER TABLE users ALTER COLUMN role DROP DEFAULT');
}
}# Команди керування міграціями
php bin/console doctrine:migrations:status # Статус міграцій
php bin/console doctrine:migrations:migrate # Виконати міграції
php bin/console doctrine:migrations:migrate prev # Відкотити останню
php bin/console doctrine:migrations:diff # Згенерувати міграцію зі схеми
php bin/console doctrine:migrations:execute --down # Конкретний rollbackСтратегія expand-contract (3 деплої) забезпечує деплой без простоїв: додати nullable → мігрувати дані → додати обмеження.
Висновок
Ці 25 питань охоплюють основу співбесід Symfony — від основ Service Container до складних виробничих патернів.
Чек-лист підготовки:
- ✅ Service Container і dependency injection
- ✅ Doctrine ORM: звʼязки, запити, фільтри
- ✅ Безпека: автентифікація, voters, JWT
- ✅ Форми та власна валідація
- ✅ Messenger: асинхронна обробка та робота з помилками
- ✅ Тести: модульні, функціональні, fixtures
- ✅ Архітектура: CQRS, Repository Pattern
- ✅ API Platform: REST, власні операції
- ✅ Продакшн: продуктивність, логування, деплой
Кожне питання заслуговує глибшого вивчення з офіційною документацією Symfony. Рекрутери цінують кандидатів, які розуміють архітектурні рішення фреймворку та можуть обґрунтувати свій технічний вибір.
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Теги
Поділитися
Пов'язані статті

Symfony 8 у 2026 році: нові можливості, PHP 8.4 Lazy Objects та питання для співбесід
Symfony 8 new features: нативні lazy objects PHP 8.4, багатокрокові форми, invokable-команди, JSON Streamer та питання для технічних співбесід 2026.

Doctrine ORM: Опанування зв'язків у Symfony
Повний посібник зі зв'язків Doctrine ORM у Symfony. OneToMany, ManyToMany, стратегії завантаження та оптимізація продуктивності з практичними прикладами.

Symfony 7 та API Platform: найкращі практики розробки REST API
Повний практичний посібник з API Platform 4 та Symfony 7: State Processors, State Providers, серіалізація, фільтри, безпека та тестування REST API на PHP.