Pertanyaan Wawancara Symfony: Top 25 di 2026
25 pertanyaan wawancara Symfony paling sering ditanyakan. Arsitektur, Doctrine ORM, service, keamanan, form, dan testing dengan jawaban detail dan contoh kode.

Wawancara Symfony menguji penguasaan framework PHP profesional yang menjadi referensi, pemahaman arsitektur berbasis komponen, ORM Doctrine, dan kemampuan membangun aplikasi yang kokoh dan dapat diskalakan. Panduan ini membahas 25 pertanyaan paling sering ditanyakan, dari fundamental Symfony hingga pola produksi tingkat lanjut.
Recruiter menghargai kandidat yang memahami filosofi Symfony: pemisahan melalui service, konfigurasi eksplisit, dan kepatuhan pada standar PSR. Mampu menjelaskan keputusan arsitektural framework membuat perbedaan.
Fundamental Symfony
Pertanyaan 1: Jelaskan siklus hidup request di Symfony
Siklus hidup request Symfony melewati HTTP Kernel dan menggunakan sistem event untuk memungkinkan ekstensi pada setiap langkah. Memahaminya sangat penting untuk debugging dan menyesuaikan perilaku aplikasi.
// Titik masuk untuk semua request HTTP
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
// Symfony Runtime menangani bootstrap
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};// Kernel mengorkestrasi pemrosesan request
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
// Kernel memuat bundle dan mengonfigurasi container
// Event kunci dalam siklus hidup:
// 1. kernel.request - Sebelum routing
// 2. kernel.controller - Setelah resolusi controller
// 3. kernel.view - Jika controller tidak mengembalikan Response
// 4. kernel.response - Sebelum mengirim respons
// 5. kernel.terminate - Setelah dikirim (tugas asinkron)
}Siklus lengkap: index.php → Runtime → Kernel → HttpKernel → EventDispatcher (kernel.request) → Router → Controller → EventDispatcher (kernel.response) → Response. Setiap langkah dapat diintersep melalui Event Subscribers.
Pertanyaan 2: Apa itu Service Container dan Dependency Injection di Symfony?
Service Container (atau DIC, Dependency Injection Container) adalah jantung Symfony. Ia mengelola pembuatan instance, konfigurasi, dan injeksi semua service aplikasi.
// Service dengan dependency yang diinjeksikan secara otomatis
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, // Klien HTTP yang sudah dikonfigurasi
private readonly OrderRepository $orderRepository, // Repository Doctrine
private readonly LoggerInterface $logger, // Logger PSR-3
private readonly string $stripeApiKey, // Parameter yang diinjeksikan
) {}
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
# Konfigurasi service
services:
_defaults:
autowire: true # Injeksi otomatis berdasarkan type-hint
autoconfigure: true # Konfigurasi tag otomatis
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# Konfigurasi eksplisit dengan parameter
App\Service\PaymentService:
arguments:
$stripeClient: '@stripe.client'
$stripeApiKey: '%env(STRIPE_API_KEY)%'Autowiring secara otomatis menyelesaikan dependency berdasarkan type-hint. Parameter skalar memerlukan konfigurasi eksplisit.
Pertanyaan 3: Apa perbedaan antara Bundle dan komponen Symfony?
Bundle adalah paket yang dapat digunakan kembali yang mengintegrasikan fungsionalitas ke dalam aplikasi Symfony. Komponen adalah library PHP mandiri yang dapat digunakan tanpa Symfony.
// Struktur Bundle kustom
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
{
// Memuat konfigurasi bundle
public function loadExtension(
array $config,
ContainerConfigurator $container,
ContainerBuilder $builder
): void {
// Memuat service bundle
$container->import('../config/services.yaml');
// Konfigurasi kondisional
if ($config['feature_enabled']) {
$container->services()
->set('my_bundle.feature_service', FeatureService::class)
->autowire();
}
}
// Konfigurasi default bundle
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->booleanNode('feature_enabled')->defaultTrue()->end()
->scalarNode('api_key')->isRequired()->end()
->end();
}
}// Penggunaan komponen tanpa Symfony
// Komponen adalah library PHP mandiri
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
// Dapat digunakan di proyek PHP apa pun
$request = Request::createFromGlobals();
$response = new Response('Hello World', 200);
$response->send();Bundle merangkum konfigurasi, service, dan resource. Komponen adalah alat tingkat rendah yang dapat digunakan kembali di mana saja.
Pertanyaan 4: Bagaimana cara kerja Event Subscribers di Symfony?
Event Subscribers memungkinkan bereaksi pada event framework atau aplikasi, memisahkan logika bisnis dari kode utama.
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
{
// Mendeklarasikan event yang didengarkan dan prioritasnya
public static function getSubscribedEvents(): array
{
return [
// Prioritas tinggi (dijalankan sebelum yang lain)
KernelEvents::EXCEPTION => ['onKernelException', 100],
KernelEvents::RESPONSE => ['onKernelResponse', 0],
];
}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
$request = $event->getRequest();
// Hanya menangani request 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);
// Mengganti respons dengan JSON kita
$event->setResponse($response);
}
public function onKernelResponse(\Symfony\Component\HttpKernel\Event\ResponseEvent $event): void
{
// Menambahkan header kustom
$event->getResponse()->headers->set('X-Api-Version', '1.0');
}
}Event Subscribers ditemukan secara otomatis berkat autoconfigure. Prioritas menentukan urutan eksekusi (lebih tinggi = dijalankan lebih dulu).
Doctrine ORM
Pertanyaan 5: Jelaskan relasi Doctrine dan perbedaannya
Doctrine menawarkan beberapa jenis relasi untuk memodelkan asosiasi antar entitas. Setiap jenis berdampak pada query dan performa.
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;
// Relasi OneToOne: satu user memiliki satu profil
#[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
private ?Profile $profile = null;
// Relasi OneToMany: satu user memiliki banyak artikel
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Article::class, orphanRemoval: true)]
private Collection $articles;
// Relasi ManyToMany: banyak user memiliki banyak role
#[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')]
#[ORM\JoinTable(name: 'user_roles')]
private Collection $roles;
public function __construct()
{
// Inisialisasi koleksi yang wajib
$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); // Sinkronisasi dua arah
}
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
{
// Relasi ManyToOne: banyak artikel dimiliki oleh satu penulis
#[ORM\ManyToOne(inversedBy: 'articles')]
#[ORM\JoinColumn(nullable: false)]
private ?User $author = null;
// ManyToMany dengan atribut tambahan via entitas pivot
#[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticleTag::class)]
private Collection $articleTags;
}Relasi dua arah memerlukan sinkronisasi manual. Sisi "owning" (dengan JoinColumn/JoinTable) mengontrol persistence.
Pertanyaan 6: Apa itu masalah N+1 dan bagaimana cara mengatasinya dengan Doctrine?
Masalah N+1 terjadi ketika satu query utama menghasilkan N query tambahan untuk memuat relasi. Ini adalah penyebab paling umum kelambatan dalam aplikasi 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);
}
// BURUK: N+1 query saat mengakses penulis
public function findAllBad(): array
{
return $this->findAll();
// + 1 query per artikel untuk memuat penulis
}
// BAIK: JOIN dengan eager fetch
public function findAllWithAuthor(): array
{
return $this->createQueryBuilder('a')
->addSelect('u') // SELECT juga penulis
->leftJoin('a.author', 'u') // JOIN pada relasi
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
// BAIK: beberapa JOIN untuk beberapa relasi
public function findAllWithDetails(): array
{
return $this->createQueryBuilder('a')
->addSelect('u', 'c', 't') // SELECT semua relasi
->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();
}
// BAIK: pemuatan batch untuk daftar besar
public function findAllOptimized(): array
{
$query = $this->createQueryBuilder('a')
->getQuery();
// Memuat relasi dalam batch 100
$query->setFetchMode(Article::class, 'author', ClassMetadata::FETCH_BATCH);
return $query->getResult();
}
}Symfony Profiler dengan panel Doctrine membantu mendeteksi masalah N+1. Jumlah query tampil di Web Debug Toolbar.
Pertanyaan 7: Bagaimana cara membuat Query Extensions dan filter Doctrine?
Query Extensions dan filter Doctrine memungkinkan menerapkan kondisi secara otomatis ke semua query — ideal untuk multi-tenancy atau soft delete.
// Extension API Platform untuk memfilter berdasarkan user secara otomatis
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
{
// Hanya berlaku untuk Article
if ($resourceClass !== Article::class) {
return;
}
// Admin melihat semua
if ($this->security->isGranted('ROLE_ADMIN')) {
return;
}
$user = $this->security->getUser();
$rootAlias = $queryBuilder->getRootAliases()[0];
// Filter otomatis berdasarkan penulis
$queryBuilder
->andWhere(sprintf('%s.author = :current_user', $rootAlias))
->setParameter('current_user', $user);
}
}// Filter Doctrine global untuk mengecualikan item yang dihapus
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
{
// Cek apakah entitas memiliki field 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: trueFilter berlaku pada level SQL, extension pada level QueryBuilder. Nonaktifkan sementara dengan $em->getFilters()->disable('soft_delete').
Siap menguasai wawancara Symfony Anda?
Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.
Keamanan Symfony
Pertanyaan 8: Bagaimana cara kerja sistem keamanan Symfony?
Komponen Security Symfony mengelola autentikasi (siapa user) dan otorisasi (apa yang boleh ia lakukan) melalui arsitektur yang dapat diperluas.
# 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 kustom untuk logika spesifik
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 ini hanya menangani request dengan 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 memuat user berdasarkan identifier
return new SelfValidatingPassport(
new UserBadge($apiKey, function (string $apiKey) {
// Logika memuat user berdasarkan API key
return $this->userRepository->findByApiKey($apiKey);
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// null = lanjutkan request secara normal
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse([
'error' => $exception->getMessage(),
], Response::HTTP_UNAUTHORIZED);
}
}Arsitektur keamanan bertumpu pada Firewalls (konfigurasi), Authenticators (autentikasi), dan Voters (otorisasi).
Pertanyaan 9: Bagaimana cara mengimplementasikan Voter untuk otorisasi terperinci?
Voter memungkinkan logika otorisasi yang kompleks dan dapat digunakan kembali, memisahkan aturan bisnis dari kode controller.
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 ini hanya menangani Article dan atribut ini
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;
// Artikel yang dipublikasi terlihat untuk semua orang
if ($attribute === self::VIEW && $article->isPublished()) {
return true;
}
// Tindakan lain memerlukan user yang terautentikasi
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
{
// Draft hanya terlihat oleh penulis atau admin
return $article->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
private function canEdit(Article $article, User $user): bool
{
// Hanya penulis yang dapat mengedit
return $article->getAuthor() === $user;
}
private function canDelete(Article $article, User $user): bool
{
// Penulis atau admin dapat menghapus
return $article->getAuthor() === $user
|| in_array('ROLE_ADMIN', $user->getRoles());
}
}// Penggunaan Voter di controller
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
{
// Otorisasi diperiksa secara otomatis
// 403 jika voter menolak akses
}
// Alternatif programatik
#[Route('/articles/{id}', name: 'article_show')]
public function show(Article $article): Response
{
$this->denyAccessUnlessGranted(ArticleVoter::VIEW, $article);
// Atau dengan kondisi
if (!$this->isGranted(ArticleVoter::EDIT, $article)) {
// Sembunyikan tombol edit
}
}
}{# Di Twig #}
{% if is_granted('ARTICLE_EDIT', article) %}
<a href="{{ path('article_edit', {id: article.id}) }}">Edit</a>
{% endif %}Voter ditemukan secara otomatis dan dikonsultasikan saat panggilan isGranted(). Strategi default memberi akses bila setidaknya satu Voter memberi suara positif.
Pertanyaan 10: Bagaimana cara mengamankan API dengan JWT di Symfony?
Autentikasi JWT (JSON Web Token) adalah solusi standar untuk API stateless. Symfony biasanya menggunakan 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 jamnamespace 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);
}
// Buat token 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
{
// Ditangani otomatis oleh bundle jika dikonfigurasi
// Mengembalikan token baru dari refresh token
}
}// Kustomisasi 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();
// Tambahkan data kustom ke token
$payload['user_id'] = $user->getId();
$payload['email'] = $user->getEmail();
$payload['permissions'] = $user->getPermissions();
$event->setData($payload);
}
}Token JWT dikirim di header Authorization: Bearer <token>. Bundle secara otomatis memverifikasi tanda tangan dan kedaluwarsa.
Form Symfony
Pertanyaan 11: Bagaimana cara membuat form lanjutan dengan validasi?
Komponen Form Symfony menghasilkan form HTML, mengelola pengiriman, dan memvalidasi data dengan constraint.
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' => 'Judul artikel',
'attr' => ['placeholder' => 'Masukkan judul...'],
'constraints' => [
new Assert\NotBlank(message: 'Judul wajib diisi'),
new Assert\Length(
min: 10,
max: 255,
minMessage: 'Judul minimal {{ limit }} karakter',
),
],
])
->add('content', TextareaType::class, [
'label' => 'Konten',
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 100),
],
])
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'placeholder' => 'Pilih kategori',
'query_builder' => function ($repo) {
return $repo->createQueryBuilder('c')
->where('c.active = true')
->orderBy('c.name', 'ASC');
},
])
->add('coverImage', FileType::class, [
'label' => 'Gambar sampul',
'mapped' => false, // Tidak terkait dengan entitas
'required' => false,
'constraints' => [
new Assert\Image(
maxSize: '5M',
mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
mimeTypesMessage: 'Format gambar tidak didukung',
),
],
])
->add('publishedAt', DateTimeType::class, [
'widget' => 'single_text',
'required' => false,
'label' => 'Tanggal publikasi',
]);
// Event listener untuk memodifikasi form secara dinamis
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$article = $event->getData();
$form = $event->getForm();
// Tambahkan field hanya saat edit
if ($article && $article->getId()) {
$form->add('slug', TextType::class, [
'disabled' => true,
'help' => 'Slug tidak dapat diubah',
]);
}
});
}
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()) {
// Penanganan upload file
$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', 'Artikel berhasil dibuat!');
return $this->redirectToRoute('article_show', ['id' => $article->getId()]);
}
return $this->render('article/new.html.twig', [
'form' => $form,
]);
}FormEvents (PRE_SET_DATA, POST_SUBMIT, dll.) memungkinkan memodifikasi field secara dinamis berdasarkan konteks.
Pertanyaan 12: Bagaimana cara mengimplementasikan validasi kustom dengan constraint?
Symfony memungkinkan membuat constraint validasi kustom untuk aturan bisnis kompleks.
// Constraint kustom
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UniqueEmail extends Constraint
{
public string $message = 'Email "{{ value }}" sudah digunakan.';
public ?int $excludeId = null; // Untuk mengecualikan user saat ini ketika update
}// Validator yang terkait dengan 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 menangani nilai kosong
}
$existingUser = $this->userRepository->findOneBy(['email' => $value]);
// Cek apakah user dengan email ini ada
// dan bukan user saat ini (saat update)
if ($existingUser && $existingUser->getId() !== $constraint->excludeId) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}
}// Penggunaan constraint pada entitas
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: 'Password harus mengandung huruf besar, huruf kecil, dan angka'
)]
private ?string $plainPassword = null;
}// Constraint pada level kelas untuk validasi multi-field
// src/Validator/PasswordMatch.php
#[\Attribute(\Attribute::TARGET_CLASS)]
class PasswordMatch extends Constraint
{
public string $message = 'Password tidak cocok.';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}Constraint kustom ditemukan secara otomatis. Sufiks Validator wajib untuk validator.
Messenger dan komunikasi asinkron
Pertanyaan 13: Bagaimana cara mengimplementasikan pemrosesan asinkron dengan Messenger?
Symfony Messenger mengirim pesan ke antrian untuk pemrosesan asinkron, meningkatkan responsivitas aplikasi.
// Pesan (DTO yang berisi data)
namespace App\Message;
final class SendWelcomeEmail
{
public function __construct(
public readonly int $userId,
public readonly string $locale = 'en',
) {}
}// Handler yang memproses pesan
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; // User dihapus selama proses berlangsung
}
$email = (new TemplatedEmail())
->to($user->getEmail())
->subject('Selamat datang di platform kami!')
->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:
# Routing pesan ke transport async
App\Message\SendWelcomeEmail: async
App\Message\ProcessImage: async
App\Message\GenerateReport: async// Pengiriman pesan
use Symfony\Component\Messenger\MessageBusInterface;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register', methods: ['POST'])]
public function register(
Request $request,
MessageBusInterface $bus,
): Response {
// ... pembuatan user
// Dispatch asinkron - email akan dikirim di background
$bus->dispatch(new SendWelcomeEmail(
userId: $user->getId(),
locale: $request->getLocale(),
));
// Respons cepat ke user
return $this->redirectToRoute('app_login');
}
}Worker dijalankan dengan php bin/console messenger:consume async -vv. Di produksi, gunakan Supervisor untuk menjaga worker tetap berjalan.
Pertanyaan 14: Bagaimana cara menangani error dan retry dengan Messenger?
Messenger menyediakan mekanisme tangguh untuk menangani kegagalan: retry otomatis, dead letter queue, dan penanganan manual pesan yang gagal.
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) {
// Error sementara (timeout, rate limit) → retry
throw new RecoverableMessageHandlingException(
$e->getMessage(),
previous: $e
);
} catch (PaymentFailedException $e) {
// Error permanen (kartu tidak valid) → tanpa retry
throw new UnrecoverableMessageHandlingException(
$e->getMessage(),
previous: $e
);
}
}
}// Konfigurasi retry pada level pesan
namespace App\Message;
use Symfony\Component\Messenger\Stamp\DelayStamp;
final class ProcessPayment
{
public function __construct(
public readonly int $orderId,
public readonly int $attempt = 1,
) {}
// Penundaan retry kustom berdasarkan jumlah upaya
public function getRetryDelay(): int
{
return match ($this->attempt) {
1 => 5000, // 5 detik
2 => 30000, // 30 detik
3 => 300000, // 5 menit
default => 600000,
};
}
}# Perintah pengelolaan pesan yang gagal
php bin/console messenger:failed:show # Tampilkan pesan yang gagal
php bin/console messenger:failed:retry # Retry semua pesan
php bin/console messenger:failed:retry 123 # Retry pesan tertentu
php bin/console messenger:failed:remove 123 # Hapus pesanStrategi retry dan transport "failed" memastikan tidak ada pesan yang hilang. Pesan dapat dianalisis dan di-retry secara manual.
Siap menguasai wawancara Symfony Anda?
Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.
Pengujian di Symfony
Pertanyaan 15: Bagaimana cara menyusun pengujian di Symfony?
Symfony menyediakan PHPUnit dengan helper khusus untuk menguji berbagai layer aplikasi: unit, fungsional, dan integrasi.
// Unit test: menguji kelas terisolasi
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],
];
}
}// Functional test: menguji controller via 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
{
// Autentikasi
$user = $this->createUser();
$this->client->loginUser($user);
// Akses form
$crawler = $this->client->request('GET', '/articles/new');
$this->assertResponseIsSuccessful();
// Pengiriman form
$form = $crawler->selectButton('Buat')->form([
'article[title]' => 'Test Article Title',
'article[content]' => 'Ini adalah konten artikel uji saya dengan jumlah karakter yang cukup.',
]);
$this->client->submit($form);
// Verifikasi
$this->assertResponseRedirects();
$this->client->followRedirect();
$this->assertSelectorTextContains('h1', 'Test Article Title');
// Verifikasi di database
$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
{
// Bersihkan database uji
$this->entityManager->getConnection()->executeStatement('DELETE FROM article');
$this->entityManager->getConnection()->executeStatement('DELETE FROM user');
parent::tearDown();
}
}Sebaiknya pisahkan unit test (tanpa kernel), functional test (dengan kernel), dan integration test (dengan service nyata).
Pertanyaan 16: Bagaimana cara menggunakan fixtures dan DatabaseResetter?
Fixtures mengisi database dengan data uji yang realistis. Komponen DoctrineTestBundle memudahkan reset antar pengujian.
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("Konten detail artikel nomor $i...");
$article->setStatus($i <= 15 ? 'published' : 'draft');
$article->setPublishedAt($i <= 15 ? new \DateTimeImmutable("-$i days") : null);
// Referensi ke user yang dibuat oleh UserFixtures
$article->setAuthor($this->getReference('user-'.($i % 3), User::class));
$manager->persist($article);
// Buat referensi untuk fixtures lain
$this->addReference("article-$i", $article);
}
$manager->flush();
}
public function getDependencies(): array
{
// UserFixtures harus dimuat sebelum 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'];
}
}// Penggunaan DAMADoctrineTestBundle untuk reset otomatis
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); // Sesuai fixtures
}
}# Pemuatan fixtures
php bin/console doctrine:fixtures:load
php bin/console doctrine:fixtures:load --group=test
php bin/console doctrine:fixtures:load --env=testDAMADoctrineTestBundle membungkus setiap pengujian dalam transaksi yang di-rollback, sehingga fixtures tidak perlu dimuat ulang antar pengujian.
Arsitektur dan pola lanjutan
Pertanyaan 17: Bagaimana cara mengimplementasikan CQRS dengan Symfony?
CQRS (Command Query Responsibility Segregation) memisahkan operasi baca dari tulis, memungkinkan optimasi independen.
// Command: mewakili niat modifikasi
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: mengeksekusi modifikasi
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: mewakili permintaan baca
namespace App\Message\Query;
final class GetArticleBySlugQuery
{
public function __construct(
public readonly string $slug,
public readonly bool $withComments = false,
) {}
}// QueryHandler: mengambil data (dapat menggunakan read model yang dioptimasi)
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;
}
}// Penggunaan Command dan 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 memungkinkan mengoptimasi pembacaan (caching, projeksi) dan penulisan (validasi, event) secara terpisah.
Pertanyaan 18: Bagaimana cara mengimplementasikan Repository Pattern dengan benar di Symfony?
Repository Pattern di Symfony sudah hadir melalui Doctrine, tetapi dapat diperkaya dengan interface dan metode bisnis.
// Interface untuk decoupling dan testabilitas
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;
}// Implementasi Doctrine dari repository
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();
}
}
// Metode query kompleks
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
# Pengikatan interface ke implementasi
services:
App\Repository\Contract\ArticleRepositoryInterface:
alias: App\Repository\ArticleRepositoryInterface memungkinkan membuat implementasi uji (InMemoryArticleRepository) atau mengganti sumber data tanpa mengubah kode bisnis.
Pertanyaan 19: Bagaimana cara mengelola konfigurasi dan environment di Symfony?
Symfony menggunakan sistem konfigurasi fleksibel dengan dukungan variabel environment, secret, dan file YAML per environment.
# config/packages/framework.yaml
# Konfigurasi default
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 untuk produksi
framework:
session:
handler_id: '%env(REDIS_URL)%'
cookie_secure: true
when@prod:
framework:
router:
strict_requirements: null// Pengelolaan secret sensitif (terenkripsi)
// php bin/console secrets:set DATABASE_URL --env=prod
// php bin/console secrets:list --reveal --env=prod// Konfigurasi kustom untuk 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;
}
}// Injeksi konfigurasi
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,
) {}
}Secret Symfony terenkripsi dan terversi. Gunakan %env(...)% untuk variabel runtime, parameter untuk nilai statis.
Performa dan produksi
Pertanyaan 20: Bagaimana cara mengoptimasi performa aplikasi Symfony?
Optimasi mencakup beberapa tingkat: opcache, konfigurasi, cache aplikasi, dan query Doctrine.
# Konfigurasi Doctrine yang dioptimasi untuk produksi
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
# Konfigurasi cache dengan 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'// Penggunaan 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') // Cache 1 jam
->getResult();
}# Perintah optimasi untuk produksi
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
# Hasilkan proxy Doctrine
php bin/console doctrine:proxy:create-proxy-classes
# Kompilasi autoloader yang dioptimasi
composer install --no-dev --optimize-autoloader --classmap-authoritativeOPcache wajib aktif di produksi dengan pengaturan optimal. Warmup menghasilkan cache container dan router.
Pertanyaan 21: Bagaimana cara mengonfigurasi logging dan monitoring di Symfony?
Logging terstruktur dan monitoring yang tepat penting untuk mendiagnosis masalah di produksi.
# 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'// Logging terstruktur dengan konteks
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;
}
}
}// Logging request 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),
]);
}
}Format JSON memudahkan ingest oleh tool monitoring (ELK, Datadog). Channel memungkinkan pemfilteran berdasarkan tipe log.
Pertanyaan 22: Bagaimana cara men-deploy aplikasi Symfony ke produksi?
Deploy Symfony yang andal menggabungkan persiapan build, migrasi yang aman, dan switch atomik.
#!/bin/bash
# deploy.sh - Skrip deploy
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 "Membuat direktori release..."
mkdir -p $RELEASE_DIR
echo "Cloning repository..."
git clone --depth 1 --branch main git@github.com:org/repo.git $RELEASE_DIR
echo "Memasang dependency..."
cd $RELEASE_DIR
composer install --no-dev --optimize-autoloader --classmap-authoritative
echo "Menautkan file bersama..."
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 "Menjalankan migrasi..."
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
echo "Memanaskan cache..."
php bin/console cache:clear --env=prod --no-debug
php bin/console cache:warmup --env=prod --no-debug
echo "Mengatur izin..."
chown -R www-data:www-data $RELEASE_DIR
echo "Beralih ke release baru..."
ln -sfn $RELEASE_DIR $CURRENT_LINK
echo "Restart PHP-FPM..."
sudo systemctl reload php8.3-fpm
echo "Restart worker Messenger..."
php bin/console messenger:stop-workers
echo "Membersihkan release lama..."
ls -dt /var/www/releases/*/ | tail -n +6 | xargs rm -rf
echo "Deploy selesai!"# .github/workflows/deploy.yml
# Deploy CI/CD dengan 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.shDeploy atomik via symlink memungkinkan rollback seketika. Worker Messenger harus di-restart agar memuat kode baru.
API Platform dan REST
Pertanyaan 23: Bagaimana cara membangun API REST dengan API Platform?
API Platform adalah solusi standar untuk membuat API REST dan GraphQL dengan Symfony, menyediakan dokumentasi otomatis dan standar HTTP.
// Konfigurasi resource 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 kustom untuk logika bisnis
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()) {
// Artikel baru: tetapkan penulis dan 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 secara otomatis menghasilkan dokumentasi OpenAPI dan menyediakan filter, paginasi, serta validasi.
Pertanyaan 24: Bagaimana cara menyesuaikan operasi API Platform?
API Platform memungkinkan membuat operasi kustom dengan controller atau State Provider/Processor.
// Operasi kustom
#[ApiResource(
operations: [
// Operasi standar
new GetCollection(),
new Get(),
// Operasi kustom dengan controller
new Post(
uriTemplate: '/articles/{id}/publish',
controller: PublishArticleController::class,
openapi: new Model\Operation(
summary: 'Mempublikasikan artikel',
description: 'Mengubah status artikel menjadi "published"',
),
security: "is_granted('ARTICLE_EDIT', object)",
),
// Operasi dengan State Provider kustom
new GetCollection(
uriTemplate: '/articles/trending',
provider: TrendingArticlesProvider::class,
openapiContext: ['summary' => 'Artikel sedang tren'],
),
],
)]
class Article
{
// ...
}// Controller untuk operasi kustom
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('Artikel sudah dipublikasikan');
}
$article->setStatus('published');
$article->setPublishedAt(new \DateTimeImmutable());
$this->entityManager->flush();
return $article;
}
}// State Provider untuk logika baca kustom
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
{
// Logika kustom untuk artikel sedang tren
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 menangani pembacaan, State Processor menangani penulisan. Controller tetap tersedia untuk kasus kompleks.
Pertanyaan 25: Bagaimana cara mengelola migrasi database di produksi?
Migrasi Doctrine harus dirancang untuk berjalan tanpa downtime dan memungkinkan rollback yang mudah.
// Migrasi aman: menambahkan kolom 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
{
// Langkah 1: tambahkan kolom nullable
$this->addSql('ALTER TABLE users ADD role VARCHAR(50) DEFAULT NULL');
// Buat indeks dengan CONCURRENTLY (PostgreSQL - tidak memblokir)
$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');
}
}// Migrasi data (terpisah)
final class Version20260202100001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Populate role column with default value';
}
public function up(Schema $schema): void
{
// Migrasi batch untuk tabel besar
$this->addSql("UPDATE users SET role = 'ROLE_USER' WHERE role IS NULL");
}
public function down(Schema $schema): void
{
// Tidak perlu rollback untuk data
}
}// Migrasi terakhir: jadikan kolom NOT NULL
final class Version20260202100002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Make role column NOT NULL';
}
public function up(Schema $schema): void
{
// Pemeriksaan awal
$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');
}
}# Perintah pengelolaan migrasi
php bin/console doctrine:migrations:status # Status migrasi
php bin/console doctrine:migrations:migrate # Jalankan migrasi
php bin/console doctrine:migrations:migrate prev # Rollback yang terakhir
php bin/console doctrine:migrations:diff # Generate migrasi dari schema
php bin/console doctrine:migrations:execute --down # Rollback spesifikStrategi expand-contract (3 deploy) menjamin deploy tanpa downtime: tambah nullable → migrasi data → tambahkan constraint.
Kesimpulan
25 pertanyaan ini mencakup esensi wawancara Symfony, dari fundamental Service Container hingga pola produksi tingkat lanjut.
Daftar persiapan:
- ✅ Service Container dan dependency injection
- ✅ Doctrine ORM: relasi, query, filter
- ✅ Keamanan: autentikasi, voter, JWT
- ✅ Form dan validasi kustom
- ✅ Messenger: pemrosesan asinkron dan penanganan error
- ✅ Pengujian: unit, fungsional, fixtures
- ✅ Arsitektur: CQRS, Repository Pattern
- ✅ API Platform: REST, operasi kustom
- ✅ Produksi: performa, logging, deploy
Setiap pertanyaan layak dieksplorasi lebih dalam dengan dokumentasi resmi Symfony. Recruiter menghargai kandidat yang memahami pilihan arsitektural framework dan dapat menjelaskan keputusan teknis mereka.
Mulai berlatih!
Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.
Tag
Bagikan
Artikel terkait

Doctrine ORM: Menguasai Relasi di Symfony
Panduan lengkap relasi Doctrine ORM di Symfony. OneToMany, ManyToMany, strategi pemuatan, dan optimasi performa dengan contoh praktis.

Symfony Live Components dan UX 3.0: Aplikasi Reaktif Tanpa JavaScript di 2026
Symfony Live Components memungkinkan pengembangan antarmuka reaktif dengan PHP dan Twig tanpa JavaScript. Tutorial lengkap LiveProp, LiveAction, form, dan deferred loading.

Symfony 7: API Platform dan Praktik Terbaik
Panduan lengkap membangun REST API profesional dengan Symfony 7 dan API Platform 4. State Providers, Processors, validasi, dan serialisasi dijelaskan dengan contoh praktis.