คำถามสัมภาษณ์ Symfony: 25 อันดับแรกในปี 2026

25 คำถามสัมภาษณ์ Symfony ที่ถูกถามบ่อยที่สุด สถาปัตยกรรม, Doctrine ORM, บริการ, ความปลอดภัย, ฟอร์มและการทดสอบ พร้อมคำตอบละเอียดและตัวอย่างโค้ด

คำถามสัมภาษณ์ Symfony และ PHP - คู่มือฉบับสมบูรณ์

การสัมภาษณ์ Symfony ประเมินความเชี่ยวชาญในเฟรมเวิร์ก PHP มืออาชีพอ้างอิง ความเข้าใจสถาปัตยกรรมแบบเน้นคอมโพเนนต์ Doctrine ORM และความสามารถในการสร้างแอปพลิเคชันที่แข็งแรงและขยายได้ คู่มือนี้ครอบคลุม 25 คำถามที่ถูกถามมากที่สุด ตั้งแต่พื้นฐาน Symfony ไปจนถึงรูปแบบการใช้งานจริงระดับสูง

เคล็ดลับการสัมภาษณ์

ผู้สรรหาบุคลากรชื่นชอบผู้สมัครที่เข้าใจปรัชญาของ Symfony ได้แก่ การแยกงานผ่านเซอร์วิส การกำหนดค่าอย่างชัดเจน และการปฏิบัติตามมาตรฐาน PSR การอธิบายการตัดสินใจทางสถาปัตยกรรมของเฟรมเวิร์กได้คือสิ่งที่สร้างความแตกต่าง

พื้นฐาน Symfony

คำถามที่ 1: อธิบายวงจรชีวิตของ Request ใน Symfony

วงจรชีวิตของ request ใน Symfony ผ่าน HTTP Kernel และใช้ระบบ event เพื่อให้สามารถขยายได้ในแต่ละขั้น ความเข้าใจนี้สำคัญสำหรับการดีบักและปรับแต่งพฤติกรรมของแอปพลิเคชัน

public/index.phpphp
// จุดเข้าของทุก request 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']);
};
src/Kernel.phpphp
// Kernel ควบคุมการประมวลผล request
namespace App;

use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    // Kernel โหลด bundle และตั้งค่า container
    // เหตุการณ์สำคัญในวงจรชีวิต:
    // 1. kernel.request - ก่อนการ routing
    // 2. kernel.controller - หลัง resolve controller แล้ว
    // 3. kernel.view - เมื่อ controller ไม่คืน Response
    // 4. kernel.response - ก่อนส่ง response
    // 5. kernel.terminate - หลังส่งแล้ว (งาน async)
}

วงจรเต็ม: 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 ทำหน้าที่จัดการการสร้างอินสแตนซ์ การกำหนดค่า และการ inject ของบริการทั้งหมดในแอปพลิเคชัน

src/Service/PaymentService.phpphp
// บริการที่มี dependency ถูก inject อัตโนมัติ
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 client ที่ตั้งค่าไว้
        private readonly OrderRepository $orderRepository,   // Repository ของ Doctrine
        private readonly LoggerInterface $logger,            // Logger PSR-3
        private readonly string $stripeApiKey,               // พารามิเตอร์ที่ inject
    ) {}

    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;
        }
    }
}
yaml
# config/services.yaml
# การกำหนดค่าบริการ
services:
    _defaults:
        autowire: true      # Inject อัตโนมัติตาม type-hint
        autoconfigure: true # ตั้ง tag อัตโนมัติ

    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

    # การตั้งค่าโดยชัดแจ้งพร้อมพารามิเตอร์
    App\Service\PaymentService:
        arguments:
            $stripeClient: '@stripe.client'
            $stripeApiKey: '%env(STRIPE_API_KEY)%'

Autowiring จะแก้ dependency อัตโนมัติตาม type-hint พารามิเตอร์ประเภทสเกลาร์ต้องตั้งค่าโดยชัดแจ้ง

คำถามที่ 3: Bundle กับคอมโพเนนต์ Symfony ต่างกันอย่างไร

Bundle คือชุดที่ใช้ซ้ำได้ที่ผสานฟีเจอร์เข้าไปในแอปพลิเคชัน Symfony ส่วนคอมโพเนนต์เป็นไลบรารี PHP อิสระที่ใช้ได้โดยไม่ต้องมี Symfony

src/MyBundle/MyBundle.phpphp
// โครงสร้างของ 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();
    }
}
php
// การใช้คอมโพเนนต์โดยไม่มี 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 ช่วยให้ตอบสนองต่อ event ของเฟรมเวิร์กหรือแอปพลิเคชัน แยกตรรกะธุรกิจออกจากโค้ดหลักได้

src/EventSubscriber/ApiExceptionSubscriber.phpphp
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
{
    // ประกาศ event ที่สมัครและลำดับความสำคัญ
    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();

        // จัดการเฉพาะ 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);

        // แทนที่ response ด้วย JSON ของเรา
        $event->setResponse($response);
    }

    public function onKernelResponse(\Symfony\Component\HttpKernel\Event\ResponseEvent $event): void
    {
        // เพิ่ม header แบบกำหนดเอง
        $event->getResponse()->headers->set('X-Api-Version', '1.0');
    }
}

Event Subscribers ถูกค้นพบอัตโนมัติด้วย autoconfigure ลำดับความสำคัญกำหนดลำดับการทำงาน (ค่าสูงกว่า = ทำงานก่อน)

Doctrine ORM

คำถามที่ 5: อธิบายความสัมพันธ์ของ Doctrine และความแตกต่าง

Doctrine มีหลายประเภทความสัมพันธ์เพื่อจำลองการเชื่อมโยงระหว่าง entity แต่ละประเภทมีผลต่อ query และประสิทธิภาพ

src/Entity/User.phpphp
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()
    {
        // ต้อง initialize collection
        $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;
    }
}
src/Entity/Article.phpphp
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
    // ความสัมพันธ์ ManyToOne: บทความหลายบทความเป็นของผู้แต่งเดียว
    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $author = null;

    // ManyToMany พร้อมแอตทริบิวต์เพิ่มเติมผ่านอีนทิตี้พิวอท
    #[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticleTag::class)]
    private Collection $articleTags;
}

ความสัมพันธ์สองทางต้องซิงก์ด้วยมือ ฝั่ง "owning" (ที่มี JoinColumn/JoinTable) ควบคุมการบันทึก

คำถามที่ 6: ปัญหา N+1 คืออะไร และแก้ด้วย Doctrine อย่างไร

ปัญหา N+1 เกิดเมื่อ query หลักทำให้เกิด N query เพิ่มเพื่อโหลดความสัมพันธ์ เป็นสาเหตุของความช้าที่พบบ่อยที่สุดในแอปพลิเคชัน Symfony

src/Repository/ArticleRepository.phpphp
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 query เมื่อเข้าถึงผู้แต่ง
    public function findAllBad(): array
    {
        return $this->findAll();
        // + 1 query ต่อบทความเพื่อโหลดผู้แต่ง
    }

    // ดี: 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 จำนวน query ปรากฏใน Web Debug Toolbar

คำถามที่ 7: สร้าง Query Extensions และฟิลเตอร์ Doctrine อย่างไร

Query Extensions และฟิลเตอร์ Doctrine ทำให้ใช้เงื่อนไขกับทุก query โดยอัตโนมัติได้ — เหมาะกับ multi-tenancy หรือ soft delete

src/Doctrine/Extension/CurrentUserExtension.phpphp
// Extension ของ 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);
    }
}
src/Doctrine/Filter/SoftDeleteFilter.phpphp
// ฟิลเตอร์ 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
    {
        // ตรวจว่า entity มีฟิลด์ deletedAt หรือไม่
        if (!$targetEntity->hasField('deletedAt')) {
            return '';
        }

        return sprintf('%s.deleted_at IS NULL', $targetTableAlias);
    }
}
yaml
# config/packages/doctrine.yaml
doctrine:
    orm:
        filters:
            soft_delete:
                class: App\Doctrine\Filter\SoftDeleteFilter
                enabled: true

ฟิลเตอร์ทำงานในระดับ SQL ส่วน extension ทำงานที่ระดับ QueryBuilder ปิดใช้ชั่วคราวด้วย $em->getFilters()->disable('soft_delete')

พร้อมที่จะพิชิตการสัมภาษณ์ Symfony แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

ความปลอดภัยใน Symfony

คำถามที่ 8: ระบบความปลอดภัยของ Symfony ทำงานอย่างไร

คอมโพเนนต์ Security ของ Symfony จัดการการยืนยันตัวตน (ผู้ใช้คือใคร) และการอนุญาต (ทำอะไรได้) ผ่านสถาปัตยกรรมที่ขยายได้

yaml
# 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 }
src/Security/CustomAuthenticator.phpphp
// 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 นี้รองรับเฉพาะ request ที่มี 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 โหลดผู้ใช้จาก identifier
        return new SelfValidatingPassport(
            new UserBadge($apiKey, function (string $apiKey) {
                // ตรรกะโหลดผู้ใช้จาก API key
                return $this->userRepository->findByApiKey($apiKey);
            })
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // null = ดำเนินการ request ต่อตามปกติ
        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 ช่วยให้เขียนตรรกะการอนุญาตที่ซับซ้อนและนำกลับมาใช้ใหม่ได้ แยกกฎทางธุรกิจออกจากโค้ด controller

src/Security/Voter/ArticleVoter.phpphp
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());
    }
}
src/Controller/ArticleController.phpphp
// การใช้ Voter ใน 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
    {
        // การอนุญาตจะถูกตรวจอัตโนมัติ
        // 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
{# ใน 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) เป็นทางออกมาตรฐานสำหรับ API แบบ stateless โดยทั่วไป Symfony ใช้ LexikJWTAuthenticationBundle

yaml
# 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 ชั่วโมง
src/Controller/Api/AuthController.phpphp
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 token
    }
}
src/EventListener/JWTCreatedListener.phpphp
// ปรับแต่ง 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 ส่งใน header Authorization: Bearer <token> bundle จะตรวจสอบลายเซ็นและวันหมดอายุอัตโนมัติ

ฟอร์มของ Symfony

คำถามที่ 11: สร้างฟอร์มขั้นสูงพร้อม validation อย่างไร

คอมโพเนนต์ Form ของ Symfony สร้างฟอร์ม HTML จัดการการส่งและ validate ข้อมูลด้วย constraint

src/Form/ArticleType.phpphp
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,  // ไม่ผูกกับ entity
                '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' => 'วันที่เผยแพร่',
            ]);

        // event listener เพื่อปรับฟอร์มแบบไดนามิก
        $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'],
        ]);
    }
}
src/Controller/ArticleController.phpphp
#[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: สร้าง validation แบบกำหนดเองด้วย constraint อย่างไร

Symfony อนุญาตให้สร้าง constraint การ validate แบบกำหนดเองสำหรับกฎทางธุรกิจที่ซับซ้อน

src/Validator/UniqueEmail.phpphp
// constraint แบบกำหนดเอง
namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UniqueEmail extends Constraint
{
    public string $message = 'อีเมล "{{ value }}" ถูกใช้งานแล้ว';
    public ?int $excludeId = null;  // เพื่อยกเว้นผู้ใช้ปัจจุบันเมื่ออัปเดต
}
src/Validator/UniqueEmailValidator.phpphp
// 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]);

        // ตรวจว่ามีผู้ใช้ที่มีอีเมลนี้หรือไม่
        // และไม่ใช่ผู้ใช้ปัจจุบัน (ตอนอัปเดต)
        if ($existingUser && $existingUser->getId() !== $constraint->excludeId) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ value }}', $value)
                ->addViolation();
        }
    }
}
src/Entity/User.phpphp
// ใช้ constraint บน entity
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;
}
php
// constraint ระดับคลาสสำหรับ validation หลายฟิลด์
// src/Validator/PasswordMatch.php
#[\Attribute(\Attribute::TARGET_CLASS)]
class PasswordMatch extends Constraint
{
    public string $message = 'รหัสผ่านไม่ตรงกัน';

    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT;
    }
}

constraint แบบกำหนดเองถูกค้นพบอัตโนมัติ ส่วนต่อท้าย Validator จำเป็นสำหรับ validator

Messenger และการสื่อสารแบบอะซิงโครนัส

คำถามที่ 13: ทำการประมวลผลแบบอะซิงโครนัสด้วย Messenger อย่างไร

Symfony Messenger ส่งข้อความเข้า queue เพื่อประมวลผลแบบอะซิงโครนัส ช่วยให้แอปพลิเคชันตอบสนองได้ดียิ่งขึ้น

src/Message/SendWelcomeEmail.phpphp
// ข้อความ (DTO ที่บรรจุข้อมูล)
namespace App\Message;

final class SendWelcomeEmail
{
    public function __construct(
        public readonly int $userId,
        public readonly string $locale = 'en',
    ) {}
}
src/MessageHandler/SendWelcomeEmailHandler.phpphp
// handler ที่ประมวลผลข้อความ
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);
    }
}
yaml
# 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:
            # ส่งข้อความไปยัง transport async
            App\Message\SendWelcomeEmail: async
            App\Message\ProcessImage: async
            App\Message\GenerateReport: async
src/Controller/RegistrationController.phpphp
// ส่งข้อความ
use Symfony\Component\Messenger\MessageBusInterface;

class RegistrationController extends AbstractController
{
    #[Route('/register', name: 'app_register', methods: ['POST'])]
    public function register(
        Request $request,
        MessageBusInterface $bus,
    ): Response {
        // ... สร้างผู้ใช้

        // ส่งแบบอะซิงโครนัส - อีเมลจะถูกส่งในเบื้องหลัง
        $bus->dispatch(new SendWelcomeEmail(
            userId: $user->getId(),
            locale: $request->getLocale(),
        ));

        // ตอบกลับผู้ใช้ทันที
        return $this->redirectToRoute('app_login');
    }
}

เริ่ม worker ด้วย php bin/console messenger:consume async -vv ในระบบ production ใช้ Supervisor เพื่อให้ worker ทำงานต่อเนื่อง

คำถามที่ 14: จัดการข้อผิดพลาดและ retry ใน Messenger อย่างไร

Messenger มีกลไกที่แข็งแรงสำหรับจัดการความล้มเหลว: retry อัตโนมัติ, dead letter queue และการจัดการข้อความที่ล้มเหลวด้วยตนเอง

src/MessageHandler/ProcessPaymentHandler.phpphp
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
            );
        }
    }
}
src/Message/ProcessPayment.phpphp
// การตั้งค่า retry ระดับข้อความ
namespace App\Message;

use Symfony\Component\Messenger\Stamp\DelayStamp;

final class ProcessPayment
{
    public function __construct(
        public readonly int $orderId,
        public readonly int $attempt = 1,
    ) {}

    // ระยะเวลาการ retry แบบกำหนดเองตามจำนวนครั้ง
    public function getRetryDelay(): int
    {
        return match ($this->attempt) {
            1 => 5000,   // 5 วินาที
            2 => 30000,  // 30 วินาที
            3 => 300000, // 5 นาที
            default => 600000,
        };
    }
}
bash
# คำสั่งจัดการข้อความล้มเหลว
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    # ลบข้อความ

กลยุทธ์ retry และ transport "failed" ทำให้แน่ใจว่าไม่มีข้อความสูญหาย ข้อความสามารถวิเคราะห์และส่งใหม่ด้วยตนเองได้

พร้อมที่จะพิชิตการสัมภาษณ์ Symfony แล้วหรือยังครับ?

ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ

การทดสอบใน Symfony

คำถามที่ 15: จัดโครงสร้างการทดสอบใน Symfony อย่างไร

Symfony ให้ PHPUnit พร้อมเฮลเปอร์เฉพาะสำหรับทดสอบเลเยอร์ต่าง ๆ ของแอปพลิเคชัน: unit test, functional test และ integration test

tests/Unit/Service/PriceCalculatorTest.phpphp
// Unit test: ทดสอบคลาสที่แยกออกมา
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],
        ];
    }
}
tests/Functional/Controller/ArticleControllerTest.phpphp
// Functional test: ทดสอบ controller ผ่าน 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();
    }
}

ควรแยก unit test (ไม่มี kernel), functional test (มี kernel) และ integration test (ใช้บริการจริง)

คำถามที่ 16: ใช้ fixtures และ DatabaseResetter อย่างไร

Fixtures เติมข้อมูลทดสอบที่สมจริงลงในฐานข้อมูล คอมโพเนนต์ DoctrineTestBundle ช่วยให้รีเซตระหว่างเทสต์ได้ง่าย

src/DataFixtures/ArticleFixtures.phpphp
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);

            // สร้าง reference ให้ fixtures อื่น
            $this->addReference("article-$i", $article);
        }

        $manager->flush();
    }

    public function getDependencies(): array
    {
        // UserFixtures ต้องโหลดก่อน ArticleFixtures
        return [
            UserFixtures::class,
        ];
    }
}
src/DataFixtures/UserFixtures.phpphp
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'];
    }
}
tests/Functional/ArticleControllerTest.phpphp
// ใช้ 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 กำหนด
    }
}
bash
# โหลด fixtures
php bin/console doctrine:fixtures:load
php bin/console doctrine:fixtures:load --group=test
php bin/console doctrine:fixtures:load --env=test

DAMADoctrineTestBundle ห่อหุ้มเทสต์แต่ละครั้งใน transaction ที่ rollback หลีกเลี่ยงการโหลด fixtures ใหม่ระหว่างเทสต์

สถาปัตยกรรมและรูปแบบขั้นสูง

คำถามที่ 17: ทำ CQRS ด้วย Symfony อย่างไร

CQRS (Command Query Responsibility Segregation) แยกการอ่านออกจากการเขียน ทำให้ปรับแต่งแยกกันได้

src/Message/Command/CreateArticleCommand.phpphp
// 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 = [],
    ) {}
}
src/MessageHandler/Command/CreateArticleCommandHandler.phpphp
// 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;
    }
}
src/Message/Query/GetArticleBySlugQuery.phpphp
// Query: แสดงคำขออ่านข้อมูล
namespace App\Message\Query;

final class GetArticleBySlugQuery
{
    public function __construct(
        public readonly string $slug,
        public readonly bool $withComments = false,
    ) {}
}
src/MessageHandler/Query/GetArticleBySlugQueryHandler.phpphp
// 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;
    }
}
src/Controller/ArticleController.phpphp
// ใช้ Command Bus และ 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 ช่วยให้ปรับแต่งการอ่าน (caching, projection) และการเขียน (validation, event) แยกกันได้

คำถามที่ 18: นำ Repository Pattern มาใช้อย่างถูกต้องใน Symfony อย่างไร

Repository Pattern มีอยู่แล้วใน Symfony ผ่าน Doctrine แต่สามารถเพิ่ม interface และเมธอดทางธุรกิจให้สมบูรณ์ขึ้นได้

src/Repository/Contract/ArticleRepositoryInterface.phpphp
// interface เพื่อแยกส่วนและทดสอบได้ง่าย
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;
}
src/Repository/ArticleRepository.phpphp
// การ implement 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();
        }
    }

    // เมธอด query ที่ซับซ้อน
    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();
    }
}
yaml
# config/services.yaml
# ผูก interface กับการ implement
services:
    App\Repository\Contract\ArticleRepositoryInterface:
        alias: App\Repository\ArticleRepository

interface ช่วยให้สร้าง implementation ทดสอบ (InMemoryArticleRepository) หรือเปลี่ยนแหล่งข้อมูลโดยไม่ต้องแก้โค้ดทางธุรกิจ

คำถามที่ 19: จัดการคอนฟิกูเรชันและสภาพแวดล้อมใน Symfony อย่างไร

Symfony ใช้ระบบกำหนดค่าที่ยืดหยุ่นซึ่งรองรับ environment variable, secret และไฟล์ YAML ตามแต่ละสภาพแวดล้อม

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
yaml
# config/packages/prod/framework.yaml
# Override สำหรับ production
framework:
    session:
        handler_id: '%env(REDIS_URL)%'
        cookie_secure: true

when@prod:
    framework:
        router:
            strict_requirements: null
config/secrets/prod/prod.decrypt.private.phpphp
// จัดการ secret ที่อ่อนไหว (เข้ารหัส)
// php bin/console secrets:set DATABASE_URL --env=prod
// php bin/console secrets:list --reveal --env=prod
src/DependencyInjection/Configuration.phpphp
// คอนฟิกแบบกำหนดเองสำหรับ 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;
    }
}
src/Service/ConfigurableService.phpphp
// Inject ค่าคอนฟิก
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 ถูกเข้ารหัสและเก็บในระบบเวอร์ชัน ใช้ %env(...)% สำหรับตัวแปรในรันไทม์ ใช้พารามิเตอร์สำหรับค่าคงที่

ประสิทธิภาพและการขึ้นใช้งานจริง

คำถามที่ 20: ปรับประสิทธิภาพแอปพลิเคชัน Symfony อย่างไร

การปรับแต่งครอบคลุมหลายระดับ: opcache, การกำหนดค่า, แคชแอปพลิเคชัน และคิวรีของ Doctrine

config/packages/prod/doctrine.yamlphp
# คอนฟิก Doctrine ที่ปรับแต่งสำหรับ production
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
yaml
# 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'
src/Repository/ArticleRepository.phpphp
// ใช้ 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();
}
bash
# คำสั่งปรับแต่งสำหรับ production
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-authoritative

OPcache ต้องเปิดใช้งานใน production พร้อมการตั้งค่าที่เหมาะสม warmup จะสร้างแคช container และ router

คำถามที่ 21: ตั้งค่า logging และ monitoring ใน Symfony อย่างไร

Logging แบบมีโครงสร้างและ monitoring ที่เหมาะสมเป็นสิ่งจำเป็นต่อการวินิจฉัยปัญหาใน production

yaml
# 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'
src/Service/PaymentService.phpphp
// Logging แบบมีโครงสร้างพร้อมบริบท
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;
        }
    }
}
src/EventSubscriber/RequestLoggerSubscriber.phpphp
// บันทึก log ของ 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),
        ]);
    }
}

รูปแบบ JSON ช่วยให้เครื่องมือ monitoring (ELK, Datadog) อ่านข้อมูลได้ง่าย channel ช่วยกรองตามประเภท log

คำถามที่ 22: deploy แอปพลิเคชัน Symfony สู่ production อย่างไร

การ deploy Symfony ที่แข็งแรงรวมการเตรียม build, migration ที่ปลอดภัย และการสลับแบบ atomic

bash
#!/bin/bash
# deploy.sh - สคริปต์ 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 "สร้างไดเรกทอรี release..."
mkdir -p $RELEASE_DIR

echo "Clone repository..."
git clone --depth 1 --branch main git@github.com:org/repo.git $RELEASE_DIR

echo "ติดตั้ง dependency..."
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 "รัน migration..."
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 "สลับไป release ใหม่..."
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 "ลบ release เก่า..."
ls -dt /var/www/releases/*/ | tail -n +6 | xargs rm -rf

echo "Deploy เสร็จสมบูรณ์!"
yaml
# .github/workflows/deploy.yml
# Deploy 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

การ deploy แบบ atomic ผ่าน symlink ทำให้ rollback ได้ทันที worker Messenger ต้องรีสตาร์ตเพื่อโหลดโค้ดใหม่

API Platform และ REST

คำถามที่ 23: สร้าง REST API ด้วย API Platform อย่างไร

API Platform เป็นทางออกมาตรฐานสำหรับสร้าง REST และ GraphQL API ด้วย Symfony พร้อมเอกสารอัตโนมัติและมาตรฐาน HTTP

src/Entity/Article.phpphp
// คอนฟิก 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;
}
src/State/ArticleProcessor.phpphp
// 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);
    }
}
yaml
# 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 อัตโนมัติและรองรับ filter, pagination, validation

คำถามที่ 24: ปรับแต่ง operation ของ API Platform อย่างไร

API Platform อนุญาตให้สร้าง operation แบบกำหนดเองด้วย controller หรือ State Provider/Processor

src/Entity/Article.phpphp
// operation แบบกำหนดเอง
#[ApiResource(
    operations: [
        // operation มาตรฐาน
        new GetCollection(),
        new Get(),

        // operation แบบกำหนดเองพร้อม controller
        new Post(
            uriTemplate: '/articles/{id}/publish',
            controller: PublishArticleController::class,
            openapi: new Model\Operation(
                summary: 'เผยแพร่บทความ',
                description: 'เปลี่ยนสถานะบทความเป็น "published"',
            ),
            security: "is_granted('ARTICLE_EDIT', object)",
        ),

        // operation พร้อม State Provider แบบกำหนดเอง
        new GetCollection(
            uriTemplate: '/articles/trending',
            provider: TrendingArticlesProvider::class,
            openapiContext: ['summary' => 'บทความที่กำลังเป็นกระแส'],
        ),
    ],
)]
class Article
{
    // ...
}
src/Controller/PublishArticleController.phpphp
// controller สำหรับ operation แบบกำหนดเอง
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;
    }
}
src/State/TrendingArticlesProvider.phpphp
// 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 จัดการการเขียน controller ยังคงใช้ได้ในกรณีซับซ้อน

คำถามที่ 25: จัดการ migration ฐานข้อมูลใน production อย่างไร

Migration ของ Doctrine ต้องออกแบบเพื่อทำงานโดยไม่มี downtime และสามารถ rollback ได้ง่าย

migrations/Version20260202100000.phpphp
// Migration ปลอดภัย: เพิ่มคอลัมน์ 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');

        // สร้าง index แบบ CONCURRENTLY (PostgreSQL - ไม่ block)
        $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');
    }
}
migrations/Version20260202100001.phpphp
// Migration ข้อมูล (แยกออก)
final class Version20260202100001 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Populate role column with default value';
    }

    public function up(Schema $schema): void
    {
        // Migration เป็นชุดสำหรับตารางขนาดใหญ่
        $this->addSql("UPDATE users SET role = 'ROLE_USER' WHERE role IS NULL");
    }

    public function down(Schema $schema): void
    {
        // ไม่ต้อง rollback ข้อมูล
    }
}
migrations/Version20260202100002.phpphp
// Migration ขั้นสุดท้าย: ปรับคอลัมน์เป็น 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');
    }
}
bash
# คำสั่งจัดการ migration
php bin/console doctrine:migrations:status          # สถานะ migration
php bin/console doctrine:migrations:migrate         # รัน migration
php bin/console doctrine:migrations:migrate prev    # rollback migration ล่าสุด
php bin/console doctrine:migrations:diff            # สร้าง migration จาก schema
php bin/console doctrine:migrations:execute --down  # rollback เฉพาะที่ระบุ

กลยุทธ์ expand-contract (deploy 3 ครั้ง) รับประกันการ deploy แบบ zero-downtime: เพิ่ม nullable → migrate ข้อมูล → เพิ่มข้อจำกัด

บทสรุป

คำถาม 25 ข้อนี้ครอบคลุมเนื้อหาสำคัญของการสัมภาษณ์ Symfony — ตั้งแต่พื้นฐานของ Service Container ไปจนถึงรูปแบบการใช้งานจริงระดับสูง

รายการสำหรับการเตรียมตัว:

  • ✅ Service Container และ dependency injection
  • ✅ Doctrine ORM: ความสัมพันธ์, query, ฟิลเตอร์
  • ✅ ความปลอดภัย: การยืนยันตัวตน, voter, JWT
  • ✅ ฟอร์มและ validation แบบกำหนดเอง
  • ✅ Messenger: การประมวลผลแบบอะซิงโครนัสและการจัดการข้อผิดพลาด
  • ✅ การทดสอบ: unit, functional, fixtures
  • ✅ สถาปัตยกรรม: CQRS, Repository Pattern
  • ✅ API Platform: REST, operation แบบกำหนดเอง
  • ✅ Production: ประสิทธิภาพ, logging, deploy
ศึกษาเชิงลึกเพิ่มเติม

แต่ละคำถามคุ้มค่าที่จะศึกษาเพิ่มเติมจากเอกสารทางการของ Symfony ผู้สรรหาให้ความสำคัญกับผู้สมัครที่เข้าใจการตัดสินใจทางสถาปัตยกรรมของเฟรมเวิร์กและสามารถอธิบายเหตุผลในการตัดสินใจทางเทคนิค

เริ่มฝึกซ้อมเลย!

ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ

แท็ก

#symfony
#php
#interview
#doctrine
#technical interview

แชร์

บทความที่เกี่ยวข้อง