# คำถามสัมภาษณ์ Symfony: 25 อันดับแรกในปี 2026 > 25 คำถามสัมภาษณ์ Symfony ที่ถูกถามบ่อยที่สุด สถาปัตยกรรม, Doctrine ORM, บริการ, ความปลอดภัย, ฟอร์มและการทดสอบ พร้อมคำตอบละเอียดและตัวอย่างโค้ด - Published: 2026-02-02 - Updated: 2026-04-27 - Author: SharpSkill - Tags: symfony, php, interview, doctrine, technical interview - Reading time: 24 min --- การสัมภาษณ์ Symfony ประเมินความเชี่ยวชาญในเฟรมเวิร์ก PHP มืออาชีพอ้างอิง ความเข้าใจสถาปัตยกรรมแบบเน้นคอมโพเนนต์ Doctrine ORM และความสามารถในการสร้างแอปพลิเคชันที่แข็งแรงและขยายได้ คู่มือนี้ครอบคลุม 25 คำถามที่ถูกถามมากที่สุด ตั้งแต่พื้นฐาน Symfony ไปจนถึงรูปแบบการใช้งานจริงระดับสูง > **เคล็ดลับการสัมภาษณ์** > > ผู้สรรหาบุคลากรชื่นชอบผู้สมัครที่เข้าใจปรัชญาของ Symfony ได้แก่ การแยกงานผ่านเซอร์วิส การกำหนดค่าอย่างชัดเจน และการปฏิบัติตามมาตรฐาน PSR การอธิบายการตัดสินใจทางสถาปัตยกรรมของเฟรมเวิร์กได้คือสิ่งที่สร้างความแตกต่าง ## พื้นฐาน Symfony ### คำถามที่ 1: อธิบายวงจรชีวิตของ Request ใน Symfony วงจรชีวิตของ request ใน Symfony ผ่าน HTTP Kernel และใช้ระบบ event เพื่อให้สามารถขยายได้ในแต่ละขั้น ความเข้าใจนี้สำคัญสำหรับการดีบักและปรับแต่งพฤติกรรมของแอปพลิเคชัน ```php // public/index.php // จุดเข้าของทุก 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']); }; ``` ```php // src/Kernel.php // 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 ของบริการทั้งหมดในแอปพลิเคชัน ```php // src/Service/PaymentService.php // บริการที่มี 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 ```php // src/MyBundle/MyBundle.php // โครงสร้างของ 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 ของเฟรมเวิร์กหรือแอปพลิเคชัน แยกตรรกะธุรกิจออกจากโค้ดหลักได้ ```php // src/EventSubscriber/ApiExceptionSubscriber.php 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 และประสิทธิภาพ ```php // src/Entity/User.php 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; } } ``` ```php // src/Entity/Article.php #[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 ```php // src/Repository/ArticleRepository.php 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 ```php // src/Doctrine/Extension/CurrentUserExtension.php // 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); } } ``` ```php // src/Doctrine/Filter/SoftDeleteFilter.php // ฟิลเตอร์ 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 ### คำถามที่ 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 } ``` ```php // src/Security/CustomAuthenticator.php // 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 ```php // src/Security/Voter/ArticleVoter.php 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()); } } ``` ```php // src/Controller/ArticleController.php // การใช้ 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) %} แก้ไข {% 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 ชั่วโมง ``` ```php // src/Controller/Api/AuthController.php 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 } } ``` ```php // src/EventListener/JWTCreatedListener.php // ปรับแต่ง 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 ` bundle จะตรวจสอบลายเซ็นและวันหมดอายุอัตโนมัติ ## ฟอร์มของ Symfony ### คำถามที่ 11: สร้างฟอร์มขั้นสูงพร้อม validation อย่างไร คอมโพเนนต์ Form ของ Symfony สร้างฟอร์ม HTML จัดการการส่งและ validate ข้อมูลด้วย constraint ```php // src/Form/ArticleType.php 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'], ]); } } ``` ```php // src/Controller/ArticleController.php #[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 แบบกำหนดเองสำหรับกฎทางธุรกิจที่ซับซ้อน ```php // src/Validator/UniqueEmail.php // 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; // เพื่อยกเว้นผู้ใช้ปัจจุบันเมื่ออัปเดต } ``` ```php // src/Validator/UniqueEmailValidator.php // 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(); } } } ``` ```php // src/Entity/User.php // ใช้ 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 เพื่อประมวลผลแบบอะซิงโครนัส ช่วยให้แอปพลิเคชันตอบสนองได้ดียิ่งขึ้น ```php // src/Message/SendWelcomeEmail.php // ข้อความ (DTO ที่บรรจุข้อมูล) namespace App\Message; final class SendWelcomeEmail { public function __construct( public readonly int $userId, public readonly string $locale = 'en', ) {} } ``` ```php // src/MessageHandler/SendWelcomeEmailHandler.php // 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 ``` ```php // src/Controller/RegistrationController.php // ส่งข้อความ 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 และการจัดการข้อความที่ล้มเหลวด้วยตนเอง ```php // src/MessageHandler/ProcessPaymentHandler.php 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 ); } } } ``` ```php // src/Message/ProcessPayment.php // การตั้งค่า 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 ### คำถามที่ 15: จัดโครงสร้างการทดสอบใน Symfony อย่างไร Symfony ให้ PHPUnit พร้อมเฮลเปอร์เฉพาะสำหรับทดสอบเลเยอร์ต่าง ๆ ของแอปพลิเคชัน: unit test, functional test และ integration test ```php // tests/Unit/Service/PriceCalculatorTest.php // 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], ]; } } ``` ```php // tests/Functional/Controller/ArticleControllerTest.php // 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 ช่วยให้รีเซตระหว่างเทสต์ได้ง่าย ```php // src/DataFixtures/ArticleFixtures.php 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, ]; } } ``` ```php // src/DataFixtures/UserFixtures.php 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']; } } ``` ```php // tests/Functional/ArticleControllerTest.php // ใช้ 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) แยกการอ่านออกจากการเขียน ทำให้ปรับแต่งแยกกันได้ ```php // src/Message/Command/CreateArticleCommand.php // 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 = [], ) {} } ``` ```php // src/MessageHandler/Command/CreateArticleCommandHandler.php // 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; } } ``` ```php // src/Message/Query/GetArticleBySlugQuery.php // Query: แสดงคำขออ่านข้อมูล namespace App\Message\Query; final class GetArticleBySlugQuery { public function __construct( public readonly string $slug, public readonly bool $withComments = false, ) {} } ``` ```php // src/MessageHandler/Query/GetArticleBySlugQueryHandler.php // 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; } } ``` ```php // src/Controller/ArticleController.php // ใช้ 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 และเมธอดทางธุรกิจให้สมบูรณ์ขึ้นได้ ```php // src/Repository/Contract/ArticleRepositoryInterface.php // 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; } ``` ```php // src/Repository/ArticleRepository.php // การ 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 ``` ```php // config/secrets/prod/prod.decrypt.private.php // จัดการ secret ที่อ่อนไหว (เข้ารหัส) // php bin/console secrets:set DATABASE_URL --env=prod // php bin/console secrets:list --reveal --env=prod ``` ```php // src/DependencyInjection/Configuration.php // คอนฟิกแบบกำหนดเองสำหรับ 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; } } ``` ```php // src/Service/ConfigurableService.php // 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 ```php // config/packages/prod/doctrine.yaml # คอนฟิก 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' ``` ```php // src/Repository/ArticleRepository.php // ใช้ 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' ``` ```php // src/Service/PaymentService.php // 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; } } } ``` ```php // src/EventSubscriber/RequestLoggerSubscriber.php // บันทึก 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 ```php // src/Entity/Article.php // คอนฟิก 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; } ``` ```php // src/State/ArticleProcessor.php // 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 ```php // src/Entity/Article.php // 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 { // ... } ``` ```php // src/Controller/PublishArticleController.php // 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; } } ``` ```php // src/State/TrendingArticlesProvider.php // 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 ได้ง่าย ```php // migrations/Version20260202100000.php // 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'); } } ``` ```php // migrations/Version20260202100001.php // 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 ข้อมูล } } ``` ```php // migrations/Version20260202100002.php // 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 ผู้สรรหาให้ความสำคัญกับผู้สมัครที่เข้าใจการตัดสินใจทางสถาปัตยกรรมของเฟรมเวิร์กและสามารถอธิบายเหตุผลในการตัดสินใจทางเทคนิค --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/symfony/symfony-interview-questions