Initial commit
This commit is contained in:
766
skills/symfony-skill/references/api-platform.md
Normal file
766
skills/symfony-skill/references/api-platform.md
Normal file
@@ -0,0 +1,766 @@
|
||||
# API Platform & RESTful API Development
|
||||
|
||||
## Installation & Configuration
|
||||
|
||||
### Installing API Platform
|
||||
|
||||
```bash
|
||||
composer require api
|
||||
|
||||
# Or manually
|
||||
composer require symfony/serializer-pack
|
||||
composer require symfony/validator
|
||||
composer require symfony/property-access
|
||||
composer require symfony/property-info
|
||||
composer require doctrine/annotations
|
||||
composer require api-platform/core
|
||||
```
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```yaml
|
||||
# config/packages/api_platform.yaml
|
||||
api_platform:
|
||||
title: 'My API'
|
||||
version: '1.0.0'
|
||||
description: 'API documentation'
|
||||
|
||||
# Swagger/OpenAPI configuration
|
||||
swagger:
|
||||
versions: [3]
|
||||
api_keys:
|
||||
apiKey:
|
||||
name: Authorization
|
||||
type: header
|
||||
|
||||
# Collection configuration
|
||||
collection:
|
||||
pagination:
|
||||
enabled: true
|
||||
items_per_page: 30
|
||||
maximum_items_per_page: 100
|
||||
client_items_per_page: true
|
||||
client_enabled: true
|
||||
|
||||
# Format configuration
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
xml: ['application/xml', 'text/xml']
|
||||
csv: ['text/csv']
|
||||
```
|
||||
|
||||
## Creating API Resources
|
||||
|
||||
### Basic Resource
|
||||
|
||||
```php
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ApiResource(
|
||||
description: 'Product resource',
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/products',
|
||||
normalizationContext: ['groups' => ['product:list']]
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/products/{id}',
|
||||
normalizationContext: ['groups' => ['product:read']]
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/products',
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
new Put(
|
||||
uriTemplate: '/products/{id}',
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
security: "is_granted('ROLE_ADMIN') or object.owner == user"
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/products/{id}',
|
||||
denormalizationContext: ['groups' => ['product:write']],
|
||||
security: "is_granted('ROLE_ADMIN') or object.owner == user"
|
||||
),
|
||||
new Delete(
|
||||
uriTemplate: '/products/{id}',
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
)
|
||||
],
|
||||
order: ['createdAt' => 'DESC'],
|
||||
paginationEnabled: true,
|
||||
paginationItemsPerPage: 20
|
||||
)]
|
||||
class Product
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['product:read', 'product:list'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['product:read', 'product:list', 'product:write'])]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 3, max: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
#[Groups(['product:read', 'product:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
|
||||
#[Groups(['product:read', 'product:list', 'product:write'])]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Positive]
|
||||
private ?string $price = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['product:read'])]
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[Groups(['product:read'])]
|
||||
public ?User $owner = null;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Operations
|
||||
|
||||
```php
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Product;
|
||||
use App\Service\ProductService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||
|
||||
#[AsController]
|
||||
class ProductPublishController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private ProductService $productService
|
||||
) {}
|
||||
|
||||
public function __invoke(Product $data): Product
|
||||
{
|
||||
$this->productService->publish($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
// In Product entity
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
// ... other operations
|
||||
new Post(
|
||||
uriTemplate: '/products/{id}/publish',
|
||||
controller: ProductPublishController::class,
|
||||
openapiContext: [
|
||||
'summary' => 'Publish a product',
|
||||
'description' => 'Changes the product status to published',
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'Product published',
|
||||
],
|
||||
],
|
||||
],
|
||||
read: false,
|
||||
name: 'product_publish'
|
||||
)
|
||||
]
|
||||
)]
|
||||
```
|
||||
|
||||
## Filters & Search
|
||||
|
||||
### Built-in Filters
|
||||
|
||||
```php
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
|
||||
#[ApiResource]
|
||||
#[ApiFilter(SearchFilter::class, properties: [
|
||||
'name' => 'partial',
|
||||
'description' => 'partial',
|
||||
'category.name' => 'exact',
|
||||
'sku' => 'exact'
|
||||
])]
|
||||
#[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['createdAt'])]
|
||||
#[ApiFilter(BooleanFilter::class, properties: ['active', 'featured'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: [
|
||||
'name' => 'ASC',
|
||||
'price' => 'DESC',
|
||||
'createdAt' => 'DESC'
|
||||
])]
|
||||
class Product
|
||||
{
|
||||
// Entity properties
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Filters
|
||||
|
||||
```php
|
||||
namespace App\Filter;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class CustomSearchFilter extends AbstractFilter
|
||||
{
|
||||
protected function filterProperty(
|
||||
string $property,
|
||||
$value,
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
Operation $operation = null,
|
||||
array $context = []
|
||||
): void {
|
||||
if ($property !== 'search') {
|
||||
return;
|
||||
}
|
||||
|
||||
$alias = $queryBuilder->getRootAliases()[0];
|
||||
$queryBuilder
|
||||
->andWhere(sprintf('%s.name LIKE :search OR %s.description LIKE :search', $alias, $alias))
|
||||
->setParameter('search', '%' . $value . '%');
|
||||
}
|
||||
|
||||
public function getDescription(string $resourceClass): array
|
||||
{
|
||||
return [
|
||||
'search' => [
|
||||
'property' => null,
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => 'Search in name and description',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Serialization & Validation
|
||||
|
||||
### Serialization Groups
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
normalizationContext: ['groups' => ['read']],
|
||||
denormalizationContext: ['groups' => ['write']],
|
||||
operations: [
|
||||
new GetCollection(
|
||||
normalizationContext: ['groups' => ['collection']]
|
||||
),
|
||||
new Get(
|
||||
normalizationContext: ['groups' => ['item', 'read']]
|
||||
)
|
||||
]
|
||||
)]
|
||||
class Order
|
||||
{
|
||||
#[Groups(['read', 'collection', 'item'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[Groups(['read', 'collection', 'write'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[Groups(['item', 'write'])]
|
||||
private ?string $customerEmail = null;
|
||||
|
||||
#[Groups(['item'])]
|
||||
private ?array $items = [];
|
||||
|
||||
#[Groups(['read'])]
|
||||
#[SerializedName('total_amount')]
|
||||
private ?float $totalAmount = null;
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Normalizer
|
||||
|
||||
```php
|
||||
namespace App\Serializer;
|
||||
|
||||
use App\Entity\Product;
|
||||
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
|
||||
|
||||
class ProductNormalizer implements ContextAwareNormalizerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ObjectNormalizer $normalizer
|
||||
) {}
|
||||
|
||||
public function normalize($object, string $format = null, array $context = []): array
|
||||
{
|
||||
$data = $this->normalizer->normalize($object, $format, $context);
|
||||
|
||||
// Add computed fields
|
||||
$data['formatted_price'] = number_format($object->getPrice(), 2, '.', ',') . ' €';
|
||||
$data['availability'] = $object->getStock() > 0 ? 'in_stock' : 'out_of_stock';
|
||||
|
||||
// Add related data conditionally
|
||||
if (in_array('product:detailed', $context['groups'] ?? [])) {
|
||||
$data['reviews_count'] = count($object->getReviews());
|
||||
$data['average_rating'] = $this->calculateAverageRating($object);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, string $format = null, array $context = []): bool
|
||||
{
|
||||
return $data instanceof Product;
|
||||
}
|
||||
|
||||
private function calculateAverageRating(Product $product): ?float
|
||||
{
|
||||
$reviews = $product->getReviews();
|
||||
|
||||
if (count($reviews) === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = array_reduce($reviews->toArray(), function ($sum, $review) {
|
||||
return $sum + $review->getRating();
|
||||
}, 0);
|
||||
|
||||
return round($total / count($reviews), 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
```php
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource]
|
||||
class Product
|
||||
{
|
||||
#[Assert\NotBlank(groups: ['create'])]
|
||||
#[Assert\Length(
|
||||
min: 3,
|
||||
max: 255,
|
||||
groups: ['create', 'update']
|
||||
)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[Assert\Positive]
|
||||
#[Assert\LessThan(10000)]
|
||||
private ?float $price = null;
|
||||
|
||||
#[Assert\Email]
|
||||
#[Assert\NotBlank(groups: ['create'])]
|
||||
private ?string $contactEmail = null;
|
||||
|
||||
#[Assert\Valid]
|
||||
private ?Category $category = null;
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validate(ExecutionContextInterface $context): void
|
||||
{
|
||||
if ($this->startDate && $this->endDate && $this->startDate > $this->endDate) {
|
||||
$context->buildViolation('Start date must be before end date')
|
||||
->atPath('startDate')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### JWT Authentication
|
||||
|
||||
```php
|
||||
// config/packages/security.yaml
|
||||
security:
|
||||
firewalls:
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
jwt: ~
|
||||
|
||||
access_control:
|
||||
- { path: ^/api/login, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
// Login endpoint
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
|
||||
public function login(#[CurrentUser] ?User $user): JsonResponse
|
||||
{
|
||||
if (null === $user) {
|
||||
return $this->json(['message' => 'Invalid credentials'], 401);
|
||||
}
|
||||
|
||||
$token = $this->jwtManager->create($user);
|
||||
|
||||
return $this->json([
|
||||
'user' => $user->getUserIdentifier(),
|
||||
'token' => $token,
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Security
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
security: "is_granted('ROLE_USER')",
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('VIEW', object)"
|
||||
),
|
||||
new Put(
|
||||
security: "is_granted('EDIT', object)",
|
||||
securityMessage: "Only the owner can edit this resource"
|
||||
),
|
||||
new Delete(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
securityPostDenormalize: "is_granted('DELETE', object)"
|
||||
)
|
||||
]
|
||||
)]
|
||||
class Document
|
||||
{
|
||||
// Properties
|
||||
}
|
||||
```
|
||||
|
||||
## Pagination & Performance
|
||||
|
||||
### Custom Pagination
|
||||
|
||||
```php
|
||||
namespace App\Pagination;
|
||||
|
||||
use ApiPlatform\State\Pagination\PaginatorInterface;
|
||||
|
||||
class CustomPaginator implements PaginatorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private iterable $items,
|
||||
private float $currentPage,
|
||||
private float $itemsPerPage,
|
||||
private float $totalItems
|
||||
) {}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return $this->totalItems;
|
||||
}
|
||||
|
||||
public function getLastPage(): float
|
||||
{
|
||||
return ceil($this->totalItems / $this->itemsPerPage);
|
||||
}
|
||||
|
||||
public function getTotalItems(): float
|
||||
{
|
||||
return $this->totalItems;
|
||||
}
|
||||
|
||||
public function getCurrentPage(): float
|
||||
{
|
||||
return $this->currentPage;
|
||||
}
|
||||
|
||||
public function getItemsPerPage(): float
|
||||
{
|
||||
return $this->itemsPerPage;
|
||||
}
|
||||
|
||||
public function getIterator(): \Traversable
|
||||
{
|
||||
return new \ArrayIterator($this->items);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Data Provider with Optimization
|
||||
|
||||
```php
|
||||
namespace App\DataProvider;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class ProductCollectionDataProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
|
||||
{
|
||||
$queryBuilder = $this->entityManager
|
||||
->getRepository(Product::class)
|
||||
->createQueryBuilder('p')
|
||||
->leftJoin('p.category', 'c')
|
||||
->addSelect('c')
|
||||
->leftJoin('p.images', 'i')
|
||||
->addSelect('i');
|
||||
|
||||
// Apply filters
|
||||
if (isset($context['filters']['category'])) {
|
||||
$queryBuilder
|
||||
->andWhere('c.id = :category')
|
||||
->setParameter('category', $context['filters']['category']);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
$page = $context['filters']['page'] ?? 1;
|
||||
$itemsPerPage = $context['filters']['itemsPerPage'] ?? 30;
|
||||
|
||||
$queryBuilder
|
||||
->setFirstResult(($page - 1) * $itemsPerPage)
|
||||
->setMaxResults($itemsPerPage);
|
||||
|
||||
return $queryBuilder->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Subresources
|
||||
|
||||
```php
|
||||
#[ApiResource]
|
||||
class User
|
||||
{
|
||||
#[ORM\OneToMany(targetEntity: Order::class, mappedBy: 'customer')]
|
||||
#[ApiSubresource]
|
||||
private Collection $orders;
|
||||
}
|
||||
|
||||
// Access: GET /api/users/{id}/orders
|
||||
|
||||
// Custom subresource
|
||||
#[ApiResource(
|
||||
uriTemplate: '/users/{id}/orders.{_format}',
|
||||
uriVariables: [
|
||||
'id' => new Link(fromClass: User::class, identifiers: ['id'])
|
||||
],
|
||||
operations: [new GetCollection()]
|
||||
)]
|
||||
class Order
|
||||
{
|
||||
// Properties
|
||||
}
|
||||
```
|
||||
|
||||
## GraphQL Support
|
||||
|
||||
```php
|
||||
// Install GraphQL
|
||||
// composer require webonyx/graphql-php
|
||||
|
||||
#[ApiResource(
|
||||
graphQlOperations: [
|
||||
new Query(),
|
||||
new QueryCollection(
|
||||
paginationType: 'page'
|
||||
),
|
||||
new Mutation(
|
||||
name: 'create'
|
||||
),
|
||||
new Mutation(
|
||||
name: 'update'
|
||||
),
|
||||
new DeleteMutation(
|
||||
name: 'delete'
|
||||
)
|
||||
]
|
||||
)]
|
||||
class Product
|
||||
{
|
||||
// Properties
|
||||
}
|
||||
|
||||
// Custom GraphQL resolver
|
||||
#[ApiResource]
|
||||
class Order
|
||||
{
|
||||
#[GraphQL\Field]
|
||||
public function totalWithTax(): float
|
||||
{
|
||||
return $this->total * 1.2;
|
||||
}
|
||||
|
||||
#[GraphQL\Query]
|
||||
public static function ordersByStatus(string $status): array
|
||||
{
|
||||
// Custom query logic
|
||||
return [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Exception
|
||||
|
||||
```php
|
||||
namespace App\Exception;
|
||||
|
||||
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
|
||||
use Symfony\Component\Validator\ConstraintViolationList;
|
||||
|
||||
class CustomApiException extends \Exception
|
||||
{
|
||||
public function __construct(
|
||||
private string $detail,
|
||||
private int $statusCode = 400,
|
||||
private ?array $additionalData = null
|
||||
) {
|
||||
parent::__construct($detail, $statusCode);
|
||||
}
|
||||
|
||||
public function getStatusCode(): int
|
||||
{
|
||||
return $this->statusCode;
|
||||
}
|
||||
|
||||
public function getDetail(): string
|
||||
{
|
||||
return $this->detail;
|
||||
}
|
||||
|
||||
public function getAdditionalData(): ?array
|
||||
{
|
||||
return $this->additionalData;
|
||||
}
|
||||
}
|
||||
|
||||
// Exception normalizer
|
||||
class CustomExceptionNormalizer implements NormalizerInterface
|
||||
{
|
||||
public function normalize($exception, string $format = null, array $context = []): array
|
||||
{
|
||||
$data = [
|
||||
'type' => 'https://tools.ietf.org/html/rfc7231#section-6.5.1',
|
||||
'title' => 'An error occurred',
|
||||
'detail' => $exception->getDetail(),
|
||||
'status' => $exception->getStatusCode(),
|
||||
];
|
||||
|
||||
if ($additionalData = $exception->getAdditionalData()) {
|
||||
$data['additionalData'] = $additionalData;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, string $format = null): bool
|
||||
{
|
||||
return $data instanceof CustomApiException;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing API
|
||||
|
||||
```php
|
||||
namespace App\Tests\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Entity\Product;
|
||||
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
|
||||
|
||||
class ProductApiTest extends ApiTestCase
|
||||
{
|
||||
use RefreshDatabaseTrait;
|
||||
|
||||
public function testGetCollection(): void
|
||||
{
|
||||
$response = static::createClient()->request('GET', '/api/products');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertJsonContains([
|
||||
'@context' => '/api/contexts/Product',
|
||||
'@id' => '/api/products',
|
||||
'@type' => 'hydra:Collection',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testCreateProduct(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('POST', '/api/products', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => 'Test Product',
|
||||
'price' => 99.99,
|
||||
'description' => 'Test description',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains([
|
||||
'@type' => 'Product',
|
||||
'name' => 'Test Product',
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## OpenAPI/Swagger Customization
|
||||
|
||||
```php
|
||||
#[ApiResource(
|
||||
openapiContext: [
|
||||
'tags' => ['Products'],
|
||||
'summary' => 'Product management',
|
||||
'description' => 'Endpoints for managing products',
|
||||
'parameters' => [
|
||||
[
|
||||
'name' => 'X-API-VERSION',
|
||||
'in' => 'header',
|
||||
'required' => false,
|
||||
'schema' => [
|
||||
'type' => 'string',
|
||||
'default' => '1.0',
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
)]
|
||||
class Product
|
||||
{
|
||||
// Properties
|
||||
}
|
||||
```
|
||||
500
skills/symfony-skill/references/doctrine-advanced.md
Normal file
500
skills/symfony-skill/references/doctrine-advanced.md
Normal file
@@ -0,0 +1,500 @@
|
||||
# Doctrine Advanced Patterns & Optimization
|
||||
|
||||
## Query Optimization Techniques
|
||||
|
||||
### 1. Eager Loading (Avoiding N+1 Problem)
|
||||
|
||||
```php
|
||||
// Bad: N+1 queries
|
||||
$products = $repository->findAll();
|
||||
foreach ($products as $product) {
|
||||
echo $product->getCategory()->getName(); // Extra query for each product
|
||||
}
|
||||
|
||||
// Good: Eager loading with JOIN
|
||||
$products = $repository->createQueryBuilder('p')
|
||||
->leftJoin('p.category', 'c')
|
||||
->addSelect('c')
|
||||
->leftJoin('p.tags', 't')
|
||||
->addSelect('t')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
```
|
||||
|
||||
### 2. Partial Objects
|
||||
|
||||
```php
|
||||
// Load only specific fields
|
||||
$query = $em->createQuery('
|
||||
SELECT partial p.{id, name, price}
|
||||
FROM App\Entity\Product p
|
||||
WHERE p.active = true
|
||||
');
|
||||
$products = $query->getResult();
|
||||
```
|
||||
|
||||
### 3. Query Result Cache
|
||||
|
||||
```php
|
||||
$query = $em->createQuery('SELECT p FROM App\Entity\Product p')
|
||||
->useQueryCache(true)
|
||||
->useResultCache(true, 3600, 'products_list')
|
||||
->setResultCacheDriver($cache);
|
||||
```
|
||||
|
||||
## Advanced Mapping
|
||||
|
||||
### Inheritance Mapping
|
||||
|
||||
#### Single Table Inheritance
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\InheritanceType("SINGLE_TABLE")]
|
||||
#[ORM\DiscriminatorColumn(name: "type", type: "string")]
|
||||
#[ORM\DiscriminatorMap(["person" => Person::class, "employee" => Employee::class])]
|
||||
class Person
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
protected ?int $id = null;
|
||||
|
||||
#[ORM\Column]
|
||||
protected ?string $name = null;
|
||||
}
|
||||
|
||||
#[ORM\Entity]
|
||||
class Employee extends Person
|
||||
{
|
||||
#[ORM\Column]
|
||||
private ?string $department = null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Class Table Inheritance
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\InheritanceType("JOINED")]
|
||||
#[ORM\DiscriminatorColumn(name: "discr", type: "string")]
|
||||
#[ORM\DiscriminatorMap(["person" => Person::class, "employee" => Employee::class])]
|
||||
class Person
|
||||
{
|
||||
// Base class fields
|
||||
}
|
||||
```
|
||||
|
||||
### Embeddables
|
||||
|
||||
```php
|
||||
#[ORM\Embeddable]
|
||||
class Address
|
||||
{
|
||||
#[ORM\Column]
|
||||
private ?string $street = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private ?string $zipCode = null;
|
||||
}
|
||||
|
||||
#[ORM\Entity]
|
||||
class User
|
||||
{
|
||||
#[ORM\Embedded(class: Address::class)]
|
||||
private ?Address $address = null;
|
||||
}
|
||||
```
|
||||
|
||||
## Repository Patterns
|
||||
|
||||
### Specification Pattern
|
||||
|
||||
```php
|
||||
interface Specification
|
||||
{
|
||||
public function modify(QueryBuilder $qb, string $alias): void;
|
||||
}
|
||||
|
||||
class ActiveProductSpecification implements Specification
|
||||
{
|
||||
public function modify(QueryBuilder $qb, string $alias): void
|
||||
{
|
||||
$qb->andWhere("$alias.active = :active")
|
||||
->setParameter('active', true);
|
||||
}
|
||||
}
|
||||
|
||||
class PriceRangeSpecification implements Specification
|
||||
{
|
||||
public function __construct(
|
||||
private float $min,
|
||||
private float $max
|
||||
) {}
|
||||
|
||||
public function modify(QueryBuilder $qb, string $alias): void
|
||||
{
|
||||
$qb->andWhere("$alias.price BETWEEN :min AND :max")
|
||||
->setParameter('min', $this->min)
|
||||
->setParameter('max', $this->max);
|
||||
}
|
||||
}
|
||||
|
||||
// Repository implementation
|
||||
class ProductRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function findBySpecifications(array $specifications): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('p');
|
||||
|
||||
foreach ($specifications as $specification) {
|
||||
$specification->modify($qb, 'p');
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
$products = $repository->findBySpecifications([
|
||||
new ActiveProductSpecification(),
|
||||
new PriceRangeSpecification(10.00, 100.00)
|
||||
]);
|
||||
```
|
||||
|
||||
### Custom Hydration
|
||||
|
||||
```php
|
||||
class SimpleArrayHydrator extends AbstractHydrator
|
||||
{
|
||||
protected function hydrateAllData()
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->_stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$result[] = $row;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Register hydrator
|
||||
$em->getConfiguration()->addCustomHydrationMode(
|
||||
'SimpleArrayHydrator',
|
||||
SimpleArrayHydrator::class
|
||||
);
|
||||
|
||||
// Use custom hydrator
|
||||
$query = $em->createQuery('SELECT p.name, p.price FROM App\Entity\Product p');
|
||||
$results = $query->getResult('SimpleArrayHydrator');
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
### Complex Migrations
|
||||
|
||||
```php
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20240101120000 extends AbstractMigration
|
||||
{
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// DDL changes
|
||||
$this->addSql('ALTER TABLE product ADD discount_price DECIMAL(10, 2) DEFAULT NULL');
|
||||
|
||||
// Data migration
|
||||
$this->addSql('UPDATE product SET discount_price = price * 0.9 WHERE on_sale = true');
|
||||
}
|
||||
|
||||
public function postUp(Schema $schema): void
|
||||
{
|
||||
// Complex data migrations after schema change
|
||||
$connection = $this->connection;
|
||||
$products = $connection->fetchAllAssociative('SELECT id, price FROM product');
|
||||
|
||||
foreach ($products as $product) {
|
||||
// Complex calculation
|
||||
$newPrice = $this->calculateNewPrice($product['price']);
|
||||
$connection->update('product',
|
||||
['calculated_price' => $newPrice],
|
||||
['id' => $product['id']]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE product DROP discount_price');
|
||||
}
|
||||
|
||||
private function calculateNewPrice(float $price): float
|
||||
{
|
||||
// Complex business logic
|
||||
return $price * 1.1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Event Listeners & Lifecycle Callbacks
|
||||
|
||||
### Entity Listeners
|
||||
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\EntityListeners([ProductListener::class])]
|
||||
class Product
|
||||
{
|
||||
// Entity code
|
||||
}
|
||||
|
||||
class ProductListener
|
||||
{
|
||||
#[ORM\PrePersist]
|
||||
public function prePersist(Product $product, LifecycleEventArgs $args): void
|
||||
{
|
||||
$product->setCreatedAt(new \DateTimeImmutable());
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function preUpdate(Product $product, PreUpdateEventArgs $args): void
|
||||
{
|
||||
if ($args->hasChangedField('price')) {
|
||||
$oldPrice = $args->getOldValue('price');
|
||||
$newPrice = $args->getNewValue('price');
|
||||
|
||||
// Log price change
|
||||
$this->logger->info('Price changed', [
|
||||
'product' => $product->getId(),
|
||||
'old' => $oldPrice,
|
||||
'new' => $newPrice
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Doctrine Event Subscriber
|
||||
|
||||
```php
|
||||
use Doctrine\Common\EventSubscriber;
|
||||
use Doctrine\ORM\Events;
|
||||
use Doctrine\Persistence\Event\LifecycleEventArgs;
|
||||
|
||||
class TimestampableSubscriber implements EventSubscriber
|
||||
{
|
||||
public function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
Events::prePersist,
|
||||
Events::preUpdate,
|
||||
];
|
||||
}
|
||||
|
||||
public function prePersist(LifecycleEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
|
||||
if ($entity instanceof TimestampableInterface) {
|
||||
$entity->setCreatedAt(new \DateTimeImmutable());
|
||||
$entity->setUpdatedAt(new \DateTimeImmutable());
|
||||
}
|
||||
}
|
||||
|
||||
public function preUpdate(LifecycleEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
|
||||
if ($entity instanceof TimestampableInterface) {
|
||||
$entity->setUpdatedAt(new \DateTimeImmutable());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Queries
|
||||
|
||||
### Native SQL Queries
|
||||
|
||||
```php
|
||||
$rsm = new ResultSetMappingBuilder($em);
|
||||
$rsm->addRootEntityFromClassMetadata(Product::class, 'p');
|
||||
|
||||
$sql = "
|
||||
SELECT p.*
|
||||
FROM product p
|
||||
WHERE MATCH(p.name, p.description) AGAINST(:search IN BOOLEAN MODE)
|
||||
ORDER BY MATCH(p.name, p.description) AGAINST(:search) DESC
|
||||
";
|
||||
|
||||
$query = $em->createNativeQuery($sql, $rsm);
|
||||
$query->setParameter('search', $searchTerm);
|
||||
$results = $query->getResult();
|
||||
```
|
||||
|
||||
### DQL Custom Functions
|
||||
|
||||
```php
|
||||
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
|
||||
|
||||
class MatchAgainst extends FunctionNode
|
||||
{
|
||||
protected $columns = [];
|
||||
protected $needle;
|
||||
protected $mode;
|
||||
|
||||
public function parse(\Doctrine\ORM\Query\Parser $parser): void
|
||||
{
|
||||
$parser->match(Lexer::T_IDENTIFIER);
|
||||
$parser->match(Lexer::T_OPEN_PARENTHESIS);
|
||||
|
||||
$this->columns[] = $parser->StateFieldPathExpression();
|
||||
|
||||
while ($parser->getLexer()->isNextToken(Lexer::T_COMMA)) {
|
||||
$parser->match(Lexer::T_COMMA);
|
||||
$this->columns[] = $parser->StateFieldPathExpression();
|
||||
}
|
||||
|
||||
$parser->match(Lexer::T_COMMA);
|
||||
$this->needle = $parser->StringPrimary();
|
||||
|
||||
$parser->match(Lexer::T_CLOSE_PARENTHESIS);
|
||||
}
|
||||
|
||||
public function getSql(\Doctrine\ORM\Query\SqlWalker $sqlWalker): string
|
||||
{
|
||||
$columns = [];
|
||||
foreach ($this->columns as $column) {
|
||||
$columns[] = $column->dispatch($sqlWalker);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'MATCH(%s) AGAINST(%s IN BOOLEAN MODE)',
|
||||
implode(', ', $columns),
|
||||
$this->needle->dispatch($sqlWalker)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register function
|
||||
$config->addCustomStringFunction('MATCH', MatchAgainst::class);
|
||||
```
|
||||
|
||||
## Batch Processing
|
||||
|
||||
```php
|
||||
class BatchProcessor
|
||||
{
|
||||
private const BATCH_SIZE = 100;
|
||||
|
||||
public function processBatch(EntityManagerInterface $em): void
|
||||
{
|
||||
$offset = 0;
|
||||
|
||||
while (true) {
|
||||
$products = $em->getRepository(Product::class)
|
||||
->createQueryBuilder('p')
|
||||
->setFirstResult($offset)
|
||||
->setMaxResults(self::BATCH_SIZE)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if (empty($products)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($products as $product) {
|
||||
// Process product
|
||||
$this->processProduct($product);
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
$em->clear(); // Detach entities to free memory
|
||||
|
||||
$offset += self::BATCH_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
public function iterableProcess(EntityManagerInterface $em): void
|
||||
{
|
||||
$query = $em->getRepository(Product::class)
|
||||
->createQueryBuilder('p')
|
||||
->getQuery();
|
||||
|
||||
foreach ($query->toIterable() as $product) {
|
||||
$this->processProduct($product);
|
||||
|
||||
if (($i++ % self::BATCH_SIZE) === 0) {
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
}
|
||||
}
|
||||
|
||||
$em->flush();
|
||||
$em->clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Connection Management
|
||||
|
||||
### Multiple Entity Managers
|
||||
|
||||
```yaml
|
||||
# config/packages/doctrine.yaml
|
||||
doctrine:
|
||||
dbal:
|
||||
default_connection: default
|
||||
connections:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
analytics:
|
||||
url: '%env(resolve:ANALYTICS_DATABASE_URL)%'
|
||||
|
||||
orm:
|
||||
default_entity_manager: default
|
||||
entity_managers:
|
||||
default:
|
||||
connection: default
|
||||
mappings:
|
||||
App:
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
analytics:
|
||||
connection: analytics
|
||||
mappings:
|
||||
Analytics:
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity/Analytics'
|
||||
prefix: 'App\Entity\Analytics'
|
||||
```
|
||||
|
||||
### Read/Write Splitting
|
||||
|
||||
```yaml
|
||||
doctrine:
|
||||
dbal:
|
||||
connections:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
replicas:
|
||||
replica1:
|
||||
url: '%env(resolve:DATABASE_REPLICA1_URL)%'
|
||||
replica2:
|
||||
url: '%env(resolve:DATABASE_REPLICA2_URL)%'
|
||||
```
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
1. **Use indexes properly**: Add indexes on frequently queried columns
|
||||
2. **Avoid SELECT ***: Only select needed columns
|
||||
3. **Use pagination**: Don't load all records at once
|
||||
4. **Cache metadata**: Cache entity metadata in production
|
||||
5. **Use read-only entities**: Mark entities as read-only when possible
|
||||
6. **Lazy loading vs Eager loading**: Choose based on use case
|
||||
7. **Use DTO for read operations**: Avoid hydrating full entities
|
||||
8. **Monitor slow queries**: Use Doctrine profiler in development
|
||||
9. **Use transactions wisely**: Group related operations
|
||||
10. **Clear entity manager**: Clear EM in batch operations
|
||||
731
skills/symfony-skill/references/performance-tuning.md
Normal file
731
skills/symfony-skill/references/performance-tuning.md
Normal file
@@ -0,0 +1,731 @@
|
||||
# Symfony Performance Optimization Guide
|
||||
|
||||
## Profiling & Monitoring
|
||||
|
||||
### Symfony Profiler Configuration
|
||||
|
||||
```yaml
|
||||
# config/packages/dev/web_profiler.yaml
|
||||
web_profiler:
|
||||
toolbar: true
|
||||
intercept_redirects: false
|
||||
|
||||
framework:
|
||||
profiler:
|
||||
only_exceptions: false
|
||||
collect: true
|
||||
only_master_requests: true
|
||||
```
|
||||
|
||||
### Blackfire Integration
|
||||
|
||||
```php
|
||||
// Install Blackfire
|
||||
// composer require blackfire/php-sdk
|
||||
|
||||
use Blackfire\Client;
|
||||
|
||||
class PerformanceService
|
||||
{
|
||||
private Client $blackfire;
|
||||
|
||||
public function profileOperation(string $name, callable $operation)
|
||||
{
|
||||
$config = new \Blackfire\Profile\Configuration();
|
||||
$config->setTitle($name);
|
||||
$config->setSamples(10);
|
||||
|
||||
$probe = $this->blackfire->createProbe($config);
|
||||
|
||||
$result = $operation();
|
||||
|
||||
$profile = $this->blackfire->endProbe($probe);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Optimization
|
||||
|
||||
### Query Optimization
|
||||
|
||||
```php
|
||||
namespace App\Repository;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\ORM\Query;
|
||||
|
||||
class ProductRepository extends ServiceEntityRepository
|
||||
{
|
||||
/**
|
||||
* Optimized query with proper indexing and eager loading
|
||||
*/
|
||||
public function findActiveProductsOptimized(): array
|
||||
{
|
||||
return $this->createQueryBuilder('p', 'p.id') // Index by ID
|
||||
->select('p', 'c', 'i', 't') // Select all at once
|
||||
->leftJoin('p.category', 'c')
|
||||
->leftJoin('p.images', 'i')
|
||||
->leftJoin('p.tags', 't')
|
||||
->where('p.active = :active')
|
||||
->andWhere('p.stock > :stock')
|
||||
->setParameter('active', true)
|
||||
->setParameter('stock', 0)
|
||||
->orderBy('p.createdAt', 'DESC')
|
||||
->setMaxResults(100) // Limit results
|
||||
->getQuery()
|
||||
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true) // Force partial loading
|
||||
->useQueryCache(true) // Use query cache
|
||||
->useResultCache(true, 3600) // Cache for 1 hour
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use raw SQL for complex queries
|
||||
*/
|
||||
public function findProductsWithComplexCalculation(): array
|
||||
{
|
||||
$sql = "
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.price,
|
||||
COUNT(DISTINCT o.id) as order_count,
|
||||
SUM(oi.quantity) as total_sold,
|
||||
AVG(r.rating) as avg_rating
|
||||
FROM product p
|
||||
LEFT JOIN order_item oi ON oi.product_id = p.id
|
||||
LEFT JOIN `order` o ON o.id = oi.order_id
|
||||
LEFT JOIN review r ON r.product_id = p.id
|
||||
WHERE p.active = 1
|
||||
GROUP BY p.id
|
||||
HAVING order_count > 10
|
||||
ORDER BY total_sold DESC
|
||||
LIMIT 50
|
||||
";
|
||||
|
||||
$stmt = $this->getEntityManager()->getConnection()->prepare($sql);
|
||||
return $stmt->executeQuery()->fetchAllAssociative();
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch processing for large datasets
|
||||
*/
|
||||
public function processLargeDataset(\Closure $processor): void
|
||||
{
|
||||
$batchSize = 100;
|
||||
$offset = 0;
|
||||
|
||||
while (true) {
|
||||
$products = $this->createQueryBuilder('p')
|
||||
->setFirstResult($offset)
|
||||
->setMaxResults($batchSize)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
if (empty($products)) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($products as $product) {
|
||||
$processor($product);
|
||||
}
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
$this->getEntityManager()->clear(); // Clear to free memory
|
||||
|
||||
$offset += $batchSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Database Indexes
|
||||
|
||||
```php
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'product')]
|
||||
#[ORM\Index(name: 'idx_active_stock', columns: ['active', 'stock'])]
|
||||
#[ORM\Index(name: 'idx_category_active', columns: ['category_id', 'active'])]
|
||||
#[ORM\Index(name: 'idx_created_at', columns: ['created_at'])]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_sku', columns: ['sku'])]
|
||||
class Product
|
||||
{
|
||||
#[ORM\Column(length: 100)]
|
||||
#[ORM\Index] // Single column index
|
||||
private ?string $sku = null;
|
||||
|
||||
// Other properties...
|
||||
}
|
||||
```
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
```yaml
|
||||
# config/packages/doctrine.yaml
|
||||
doctrine:
|
||||
dbal:
|
||||
connections:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
# Connection pooling
|
||||
options:
|
||||
persistent: true
|
||||
# Maximum lifetime of a connection
|
||||
connect_timeout: 10
|
||||
# Server settings
|
||||
1002: 'SET sql_mode = TRADITIONAL'
|
||||
pool:
|
||||
min_connections: 2
|
||||
max_connections: 10
|
||||
max_idle_time: 600
|
||||
|
||||
read_replica:
|
||||
url: '%env(resolve:DATABASE_REPLICA_URL)%'
|
||||
options:
|
||||
persistent: true
|
||||
```
|
||||
|
||||
## Caching Strategies
|
||||
|
||||
### Multi-level Caching
|
||||
|
||||
```php
|
||||
namespace App\Service;
|
||||
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\Cache\TagAwareCacheInterface;
|
||||
|
||||
class CacheService
|
||||
{
|
||||
public function __construct(
|
||||
private TagAwareCacheInterface $cache,
|
||||
private CacheItemPoolInterface $redisCache,
|
||||
private CacheItemPoolInterface $apcu
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Multi-level cache with fallback
|
||||
*/
|
||||
public function getWithFallback(string $key, callable $callback, int $ttl = 3600): mixed
|
||||
{
|
||||
// Level 1: APCu (fastest, local)
|
||||
$apcuItem = $this->apcu->getItem($key);
|
||||
if ($apcuItem->isHit()) {
|
||||
return $apcuItem->get();
|
||||
}
|
||||
|
||||
// Level 2: Redis (fast, shared)
|
||||
$redisItem = $this->redisCache->getItem($key);
|
||||
if ($redisItem->isHit()) {
|
||||
$value = $redisItem->get();
|
||||
|
||||
// Store in APCu for next time
|
||||
$apcuItem->set($value);
|
||||
$apcuItem->expiresAfter(300); // 5 minutes in APCu
|
||||
$this->apcu->save($apcuItem);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Level 3: Generate and store in both caches
|
||||
$value = $callback();
|
||||
|
||||
// Store in Redis
|
||||
$redisItem->set($value);
|
||||
$redisItem->expiresAfter($ttl);
|
||||
$this->redisCache->save($redisItem);
|
||||
|
||||
// Store in APCu
|
||||
$apcuItem->set($value);
|
||||
$apcuItem->expiresAfter(300);
|
||||
$this->apcu->save($apcuItem);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache with tags for invalidation
|
||||
*/
|
||||
public function getWithTags(string $key, array $tags, callable $callback, int $ttl = 3600): mixed
|
||||
{
|
||||
return $this->cache->get($key, function (ItemInterface $item) use ($callback, $tags, $ttl) {
|
||||
$item->expiresAfter($ttl);
|
||||
$item->tag($tags);
|
||||
|
||||
return $callback();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate by tags
|
||||
*/
|
||||
public function invalidateTags(array $tags): void
|
||||
{
|
||||
$this->cache->invalidateTags($tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm cache
|
||||
*/
|
||||
public function warmCache(array $keys, callable $generator): void
|
||||
{
|
||||
foreach ($keys as $key => $params) {
|
||||
$this->cache->get($key, function () use ($generator, $params) {
|
||||
return $generator($params);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Caching
|
||||
|
||||
```php
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Attribute\Cache;
|
||||
|
||||
class ProductController extends AbstractController
|
||||
{
|
||||
#[Route('/products', name: 'product_list')]
|
||||
#[Cache(maxage: 3600, public: true, mustRevalidate: true)]
|
||||
public function index(): Response
|
||||
{
|
||||
$response = $this->render('product/index.html.twig');
|
||||
|
||||
// Set cache headers
|
||||
$response->setPublic();
|
||||
$response->setMaxAge(3600);
|
||||
$response->setSharedMaxAge(3600);
|
||||
$response->headers->addCacheControlDirective('must-revalidate', true);
|
||||
|
||||
// ETag for validation
|
||||
$response->setEtag(md5($response->getContent()));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/products/{id}', name: 'product_show')]
|
||||
public function show(Product $product, Request $request): Response
|
||||
{
|
||||
$response = new Response();
|
||||
|
||||
// Set ETag
|
||||
$etag = md5($product->getUpdatedAt()->format('c'));
|
||||
$response->setEtag($etag);
|
||||
|
||||
// Set Last-Modified
|
||||
$response->setLastModified($product->getUpdatedAt());
|
||||
|
||||
// Check if not modified
|
||||
$response->setNotModified();
|
||||
if ($response->isNotModified($request)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Generate content
|
||||
return $this->render('product/show.html.twig', [
|
||||
'product' => $product
|
||||
], $response);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ESI (Edge Side Includes)
|
||||
|
||||
```twig
|
||||
{# templates/base.html.twig #}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<header>
|
||||
{{ render_esi(controller('App\\Controller\\HeaderController::index')) }}
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<aside>
|
||||
{{ render_esi(controller('App\\Controller\\SidebarController::popularProducts', {
|
||||
'max': 5,
|
||||
'_cache': 3600
|
||||
})) }}
|
||||
</aside>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Asset Optimization
|
||||
|
||||
### Webpack Encore Configuration
|
||||
|
||||
```javascript
|
||||
// webpack.config.js
|
||||
const Encore = require('@symfony/webpack-encore');
|
||||
|
||||
Encore
|
||||
.setOutputPath('public/build/')
|
||||
.setPublicPath('/build')
|
||||
|
||||
// Enable production optimizations
|
||||
.enableSingleRuntimeChunk()
|
||||
.enableIntegrityHashes(Encore.isProduction())
|
||||
.enableBuildNotifications()
|
||||
|
||||
// Split vendor code
|
||||
.splitEntryChunks()
|
||||
|
||||
// Configure optimization
|
||||
.configureOptimizationSplitChunks((config) => {
|
||||
config.chunks = 'all';
|
||||
config.cacheGroups = {
|
||||
vendors: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
priority: 20,
|
||||
name: 'vendors',
|
||||
enforce: true
|
||||
},
|
||||
commons: {
|
||||
minChunks: 2,
|
||||
priority: 10,
|
||||
reuseExistingChunk: true
|
||||
}
|
||||
};
|
||||
})
|
||||
|
||||
// Enable compression
|
||||
.configureCompressionPlugin((options) => {
|
||||
options.algorithm = 'gzip';
|
||||
options.test = /\.(js|css|html|svg)$/;
|
||||
options.threshold = 10240;
|
||||
options.minRatio = 0.8;
|
||||
})
|
||||
|
||||
// Image optimization
|
||||
.configureImageRule({
|
||||
type: 'asset',
|
||||
maxSize: 4 * 1024 // 4 kb
|
||||
})
|
||||
|
||||
// Enable versioning
|
||||
.enableVersioning(Encore.isProduction())
|
||||
|
||||
// CDN support
|
||||
.setManifestKeyPrefix('build/')
|
||||
.configureCdn('https://cdn.example.com')
|
||||
;
|
||||
|
||||
module.exports = Encore.getWebpackConfig();
|
||||
```
|
||||
|
||||
### Lazy Loading Assets
|
||||
|
||||
```twig
|
||||
{# Lazy load images #}
|
||||
<img
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3C/svg%3E"
|
||||
data-src="{{ asset('images/product.jpg') }}"
|
||||
loading="lazy"
|
||||
alt="Product"
|
||||
class="lazyload"
|
||||
/>
|
||||
|
||||
{# Lazy load scripts #}
|
||||
<script>
|
||||
// Dynamic import for code splitting
|
||||
document.getElementById('load-feature').addEventListener('click', async () => {
|
||||
const { FeatureModule } = await import('./features/heavy-feature.js');
|
||||
FeatureModule.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
{# Preload critical assets #}
|
||||
<link rel="preload" href="{{ asset('build/app.css') }}" as="style">
|
||||
<link rel="preload" href="{{ asset('build/app.js') }}" as="script">
|
||||
<link rel="preload" href="{{ asset('fonts/main.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||
```
|
||||
|
||||
## PHP Optimization
|
||||
|
||||
### OPcache Configuration
|
||||
|
||||
```ini
|
||||
; php.ini or opcache.ini
|
||||
opcache.enable=1
|
||||
opcache.enable_cli=0
|
||||
opcache.memory_consumption=256
|
||||
opcache.interned_strings_buffer=16
|
||||
opcache.max_accelerated_files=20000
|
||||
opcache.max_wasted_percentage=10
|
||||
opcache.validate_timestamps=0
|
||||
opcache.revalidate_freq=0
|
||||
opcache.fast_shutdown=1
|
||||
opcache.enable_file_override=1
|
||||
opcache.max_file_size=0
|
||||
opcache.file_cache=/var/cache/opcache
|
||||
opcache.file_cache_only=0
|
||||
opcache.file_cache_consistency_checks=0
|
||||
|
||||
; Preload Symfony application
|
||||
opcache.preload=/var/www/config/preload.php
|
||||
opcache.preload_user=www-data
|
||||
```
|
||||
|
||||
### Preloading Script
|
||||
|
||||
```php
|
||||
// config/preload.php
|
||||
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
|
||||
// Additional files to preload
|
||||
$files = [
|
||||
__DIR__ . '/../src/Entity/',
|
||||
__DIR__ . '/../src/Repository/',
|
||||
__DIR__ . '/../src/Service/',
|
||||
];
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (is_dir($file)) {
|
||||
foreach (glob($file . '*.php') as $filename) {
|
||||
opcache_compile_file($filename);
|
||||
}
|
||||
} elseif (is_file($file)) {
|
||||
opcache_compile_file($file);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Async Processing
|
||||
|
||||
### Symfony Messenger Optimization
|
||||
|
||||
```yaml
|
||||
# config/packages/messenger.yaml
|
||||
framework:
|
||||
messenger:
|
||||
transports:
|
||||
async_priority_high:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
queue_name: high_priority
|
||||
exchange:
|
||||
name: high_priority
|
||||
type: direct
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
delay: 1000
|
||||
|
||||
async_priority_normal:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
queue_name: normal_priority
|
||||
|
||||
async_priority_low:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
queue_name: low_priority
|
||||
|
||||
routing:
|
||||
'App\Message\EmailMessage': async_priority_high
|
||||
'App\Message\ProcessImage': async_priority_normal
|
||||
'App\Message\GenerateReport': async_priority_low
|
||||
|
||||
buses:
|
||||
messenger.bus.default:
|
||||
middleware:
|
||||
- doctrine_ping_connection
|
||||
- doctrine_clear_entity_manager
|
||||
```
|
||||
|
||||
### Batch Message Processing
|
||||
|
||||
```php
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Message\ProcessOrder;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
class ProcessOrderHandler
|
||||
{
|
||||
private array $batch = [];
|
||||
private const BATCH_SIZE = 100;
|
||||
|
||||
public function __invoke(ProcessOrder $message): void
|
||||
{
|
||||
$this->batch[] = $message;
|
||||
|
||||
if (count($this->batch) >= self::BATCH_SIZE) {
|
||||
$this->processBatch();
|
||||
}
|
||||
}
|
||||
|
||||
private function processBatch(): void
|
||||
{
|
||||
// Process entire batch at once
|
||||
$orderIds = array_map(fn($msg) => $msg->getOrderId(), $this->batch);
|
||||
|
||||
$orders = $this->orderRepository->findByIds($orderIds);
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$this->processOrder($order);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
$this->batch = [];
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
if (!empty($this->batch)) {
|
||||
$this->processBatch();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Server Optimization
|
||||
|
||||
### PHP-FPM Configuration
|
||||
|
||||
```ini
|
||||
; /etc/php/8.1/fpm/pool.d/www.conf
|
||||
[www]
|
||||
pm = dynamic
|
||||
pm.max_children = 50
|
||||
pm.start_servers = 10
|
||||
pm.min_spare_servers = 5
|
||||
pm.max_spare_servers = 20
|
||||
pm.max_requests = 500
|
||||
pm.process_idle_timeout = 10s
|
||||
|
||||
; Performance tuning
|
||||
request_terminate_timeout = 30
|
||||
request_slowlog_timeout = 10s
|
||||
slowlog = /var/log/php-fpm/slow.log
|
||||
|
||||
; Resource limits
|
||||
rlimit_files = 65536
|
||||
rlimit_core = unlimited
|
||||
```
|
||||
|
||||
### Nginx Configuration
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/symfony
|
||||
server {
|
||||
server_name example.com;
|
||||
root /var/www/symfony/public;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml application/atom+xml image/svg+xml text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype;
|
||||
|
||||
# Browser caching
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# PHP-FPM
|
||||
location ~ ^/index\.php(/|$) {
|
||||
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
|
||||
fastcgi_split_path_info ^(.+\.php)(/.*)$;
|
||||
include fastcgi_params;
|
||||
|
||||
# Performance
|
||||
fastcgi_buffer_size 128k;
|
||||
fastcgi_buffers 4 256k;
|
||||
fastcgi_busy_buffers_size 256k;
|
||||
fastcgi_temp_file_write_size 256k;
|
||||
|
||||
# Cache
|
||||
fastcgi_cache_bypass $http_pragma $http_authorization;
|
||||
fastcgi_no_cache $http_pragma $http_authorization;
|
||||
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.php$is_args$args;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring & Metrics
|
||||
|
||||
```php
|
||||
namespace App\Service;
|
||||
|
||||
use Prometheus\CollectorRegistry;
|
||||
use Prometheus\Storage\Redis;
|
||||
|
||||
class MetricsService
|
||||
{
|
||||
private CollectorRegistry $registry;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Redis::setDefaultOptions([
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 6379,
|
||||
]);
|
||||
|
||||
$this->registry = new CollectorRegistry(new Redis());
|
||||
}
|
||||
|
||||
public function recordRequestDuration(string $route, float $duration): void
|
||||
{
|
||||
$histogram = $this->registry->getOrRegisterHistogram(
|
||||
'symfony',
|
||||
'request_duration_seconds',
|
||||
'Request duration in seconds',
|
||||
['route']
|
||||
);
|
||||
|
||||
$histogram->observe($duration, [$route]);
|
||||
}
|
||||
|
||||
public function incrementCounter(string $name, array $labels = []): void
|
||||
{
|
||||
$counter = $this->registry->getOrRegisterCounter(
|
||||
'symfony',
|
||||
$name,
|
||||
'Counter for ' . $name,
|
||||
array_keys($labels)
|
||||
);
|
||||
|
||||
$counter->inc(array_values($labels));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Checklist
|
||||
|
||||
1. **Enable OPcache with preloading**
|
||||
2. **Use HTTP caching headers**
|
||||
3. **Implement database query caching**
|
||||
4. **Optimize database indexes**
|
||||
5. **Use CDN for static assets**
|
||||
6. **Enable Gzip compression**
|
||||
7. **Minimize and combine assets**
|
||||
8. **Use async processing for heavy tasks**
|
||||
9. **Implement lazy loading**
|
||||
10. **Monitor and profile regularly**
|
||||
637
skills/symfony-skill/references/security-detailed.md
Normal file
637
skills/symfony-skill/references/security-detailed.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# Symfony Security Advanced Configuration
|
||||
|
||||
## Authentication Systems
|
||||
|
||||
### JWT Authentication
|
||||
|
||||
```php
|
||||
// Install required packages
|
||||
// composer require lexik/jwt-authentication-bundle
|
||||
|
||||
// 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
|
||||
|
||||
// config/packages/security.yaml
|
||||
security:
|
||||
firewalls:
|
||||
login:
|
||||
pattern: ^/api/login
|
||||
stateless: true
|
||||
json_login:
|
||||
check_path: /api/login_check
|
||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
||||
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
jwt: ~
|
||||
```
|
||||
|
||||
### OAuth2 Implementation
|
||||
|
||||
```php
|
||||
// Using KnpU OAuth2 Client Bundle
|
||||
// composer require knpuniversity/oauth2-client-bundle
|
||||
|
||||
// config/packages/knpu_oauth2_client.yaml
|
||||
knpu_oauth2_client:
|
||||
clients:
|
||||
google:
|
||||
type: google
|
||||
client_id: '%env(GOOGLE_CLIENT_ID)%'
|
||||
client_secret: '%env(GOOGLE_CLIENT_SECRET)%'
|
||||
redirect_route: connect_google_check
|
||||
redirect_params: {}
|
||||
|
||||
// Controller for OAuth
|
||||
#[Route('/connect/google', name: 'connect_google')]
|
||||
public function connectGoogle(ClientRegistry $clientRegistry): Response
|
||||
{
|
||||
return $clientRegistry
|
||||
->getClient('google')
|
||||
->redirect(['email', 'profile']);
|
||||
}
|
||||
|
||||
#[Route('/connect/google/check', name: 'connect_google_check')]
|
||||
public function connectGoogleCheck(Request $request, ClientRegistry $clientRegistry): Response
|
||||
{
|
||||
$client = $clientRegistry->getClient('google');
|
||||
$user = $client->fetchUser();
|
||||
|
||||
// Handle user creation/authentication
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Two-Factor Authentication
|
||||
|
||||
```php
|
||||
// composer require scheb/2fa-bundle scheb/2fa-totp
|
||||
|
||||
// Entity with 2FA
|
||||
#[ORM\Entity]
|
||||
class User implements UserInterface, TwoFactorInterface
|
||||
{
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?string $totpSecret = null;
|
||||
|
||||
public function isTotpAuthenticationEnabled(): bool
|
||||
{
|
||||
return $this->totpSecret !== null;
|
||||
}
|
||||
|
||||
public function getTotpAuthenticationUsername(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
|
||||
{
|
||||
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
|
||||
}
|
||||
}
|
||||
|
||||
// config/packages/security.yaml
|
||||
security:
|
||||
firewalls:
|
||||
main:
|
||||
two_factor:
|
||||
auth_form_path: 2fa_login
|
||||
check_path: 2fa_login_check
|
||||
```
|
||||
|
||||
## Custom Authenticators
|
||||
|
||||
### API Key Authenticator
|
||||
|
||||
```php
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
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 __construct(
|
||||
private UserRepository $userRepository
|
||||
) {}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
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 CustomUserMessageAuthenticationException('No API key provided');
|
||||
}
|
||||
|
||||
return new SelfValidatingPassport(
|
||||
new UserBadge($apiKey, function($apiKey) {
|
||||
$user = $this->userRepository->findOneBy(['apiKey' => $apiKey]);
|
||||
|
||||
if (!$user) {
|
||||
throw new CustomUserMessageAuthenticationException('Invalid API Key');
|
||||
}
|
||||
|
||||
return $user;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
return new JsonResponse([
|
||||
'message' => strtr($exception->getMessageKey(), $exception->getMessageData())
|
||||
], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Voters
|
||||
|
||||
### Hierarchical Voters
|
||||
|
||||
```php
|
||||
namespace App\Security\Voter;
|
||||
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
class DocumentVoter extends Voter
|
||||
{
|
||||
public const VIEW = 'DOCUMENT_VIEW';
|
||||
public const EDIT = 'DOCUMENT_EDIT';
|
||||
public const DELETE = 'DOCUMENT_DELETE';
|
||||
public const SHARE = 'DOCUMENT_SHARE';
|
||||
|
||||
protected function supports(string $attribute, mixed $subject): bool
|
||||
{
|
||||
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE, self::SHARE])
|
||||
&& $subject instanceof Document;
|
||||
}
|
||||
|
||||
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
if (!$user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var Document $document */
|
||||
$document = $subject;
|
||||
|
||||
// Check hierarchical permissions
|
||||
if ($this->hasHierarchicalAccess($user, $document, $attribute)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return match($attribute) {
|
||||
self::VIEW => $this->canView($document, $user),
|
||||
self::EDIT => $this->canEdit($document, $user),
|
||||
self::DELETE => $this->canDelete($document, $user),
|
||||
self::SHARE => $this->canShare($document, $user),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private function hasHierarchicalAccess(User $user, Document $document, string $attribute): bool
|
||||
{
|
||||
// Check department hierarchy
|
||||
if ($user->isDepartmentHead() && $document->getDepartment() === $user->getDepartment()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check organization hierarchy
|
||||
if ($user->isOrganizationAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function canView(Document $document, User $user): bool
|
||||
{
|
||||
// Public documents
|
||||
if ($document->isPublic()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Owner can view
|
||||
if ($document->getOwner() === $user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Shared with user
|
||||
if ($document->getSharedUsers()->contains($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Team member can view team documents
|
||||
if ($document->getTeam() && $document->getTeam()->hasMember($user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function canEdit(Document $document, User $user): bool
|
||||
{
|
||||
// Owner can edit
|
||||
if ($document->getOwner() === $user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check edit permissions
|
||||
return $document->hasEditPermission($user);
|
||||
}
|
||||
|
||||
private function canDelete(Document $document, User $user): bool
|
||||
{
|
||||
// Only owner and admins can delete
|
||||
return $document->getOwner() === $user || $user->hasRole('ROLE_ADMIN');
|
||||
}
|
||||
|
||||
private function canShare(Document $document, User $user): bool
|
||||
{
|
||||
// Owner and users with share permission
|
||||
return $document->getOwner() === $user || $document->hasSharePermission($user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Role Hierarchy & Dynamic Roles
|
||||
|
||||
### Dynamic Role Provider
|
||||
|
||||
```php
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;
|
||||
|
||||
class DynamicRoleHierarchy implements RoleHierarchyInterface
|
||||
{
|
||||
public function __construct(
|
||||
private RoleRepository $roleRepository
|
||||
) {}
|
||||
|
||||
public function getReachableRoleNames(array $roles): array
|
||||
{
|
||||
$reachableRoles = $roles;
|
||||
|
||||
foreach ($roles as $role) {
|
||||
$roleEntity = $this->roleRepository->findOneBy(['name' => $role]);
|
||||
|
||||
if ($roleEntity) {
|
||||
// Add inherited roles
|
||||
foreach ($roleEntity->getInheritedRoles() as $inheritedRole) {
|
||||
$reachableRoles[] = $inheritedRole->getName();
|
||||
}
|
||||
|
||||
// Add permission-based roles
|
||||
foreach ($roleEntity->getPermissions() as $permission) {
|
||||
$reachableRoles[] = 'ROLE_' . strtoupper($permission->getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($reachableRoles);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Event Listeners
|
||||
|
||||
### Login Success Handler
|
||||
|
||||
```php
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
|
||||
class LoginSuccessHandler implements AuthenticationSuccessHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private LoggerInterface $logger,
|
||||
private IpGeolocationService $geolocation
|
||||
) {}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token): Response
|
||||
{
|
||||
$user = $token->getUser();
|
||||
|
||||
// Log successful login
|
||||
$loginLog = new LoginLog();
|
||||
$loginLog->setUser($user);
|
||||
$loginLog->setIpAddress($request->getClientIp());
|
||||
$loginLog->setUserAgent($request->headers->get('User-Agent'));
|
||||
$loginLog->setTimestamp(new \DateTimeImmutable());
|
||||
|
||||
// Get geolocation
|
||||
$location = $this->geolocation->locate($request->getClientIp());
|
||||
$loginLog->setLocation($location);
|
||||
|
||||
// Check for suspicious activity
|
||||
if ($this->isSuspiciousLogin($user, $location)) {
|
||||
$this->notifyUserOfSuspiciousActivity($user, $loginLog);
|
||||
}
|
||||
|
||||
// Update last login
|
||||
$user->setLastLoginAt(new \DateTimeImmutable());
|
||||
$user->setLastLoginIp($request->getClientIp());
|
||||
|
||||
$this->em->persist($loginLog);
|
||||
$this->em->flush();
|
||||
|
||||
// Log event
|
||||
$this->logger->info('User logged in', [
|
||||
'user' => $user->getUserIdentifier(),
|
||||
'ip' => $request->getClientIp()
|
||||
]);
|
||||
|
||||
return new RedirectResponse('/dashboard');
|
||||
}
|
||||
|
||||
private function isSuspiciousLogin(User $user, ?Location $location): bool
|
||||
{
|
||||
// Check if login from new country
|
||||
$lastLogins = $this->em->getRepository(LoginLog::class)
|
||||
->findLastLogins($user, 10);
|
||||
|
||||
foreach ($lastLogins as $login) {
|
||||
if ($login->getLocation() && $login->getLocation()->getCountry() === $location->getCountry()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Access Control Lists (ACL)
|
||||
|
||||
### Custom ACL Implementation
|
||||
|
||||
```php
|
||||
namespace App\Security\Acl;
|
||||
|
||||
class AclManager
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em
|
||||
) {}
|
||||
|
||||
public function grantAccess(
|
||||
object $domainObject,
|
||||
UserInterface $user,
|
||||
array $permissions
|
||||
): void {
|
||||
$acl = new Acl();
|
||||
$acl->setObjectClass(get_class($domainObject));
|
||||
$acl->setObjectId($domainObject->getId());
|
||||
$acl->setUser($user);
|
||||
$acl->setPermissions($permissions);
|
||||
|
||||
$this->em->persist($acl);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function revokeAccess(
|
||||
object $domainObject,
|
||||
UserInterface $user
|
||||
): void {
|
||||
$acl = $this->em->getRepository(Acl::class)->findOneBy([
|
||||
'objectClass' => get_class($domainObject),
|
||||
'objectId' => $domainObject->getId(),
|
||||
'user' => $user
|
||||
]);
|
||||
|
||||
if ($acl) {
|
||||
$this->em->remove($acl);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
||||
public function isGranted(
|
||||
string $permission,
|
||||
object $domainObject,
|
||||
UserInterface $user
|
||||
): bool {
|
||||
$acl = $this->em->getRepository(Acl::class)->findOneBy([
|
||||
'objectClass' => get_class($domainObject),
|
||||
'objectId' => $domainObject->getId(),
|
||||
'user' => $user
|
||||
]);
|
||||
|
||||
return $acl && in_array($permission, $acl->getPermissions());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Headers & CORS
|
||||
|
||||
### Security Headers Subscriber
|
||||
|
||||
```php
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
KernelEvents::RESPONSE => 'onKernelResponse',
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
$response = $event->getResponse();
|
||||
|
||||
// Content Security Policy
|
||||
$response->headers->set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';"
|
||||
);
|
||||
|
||||
// XSS Protection
|
||||
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// Prevent MIME sniffing
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Clickjacking protection
|
||||
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
|
||||
|
||||
// HTTPS enforcement
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
|
||||
// Referrer Policy
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CORS Configuration
|
||||
|
||||
```yaml
|
||||
# config/packages/nelmio_cors.yaml
|
||||
nelmio_cors:
|
||||
defaults:
|
||||
origin_regex: true
|
||||
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||
allow_headers: ['Content-Type', 'Authorization', 'X-API-KEY']
|
||||
expose_headers: ['Link', 'X-Total-Count']
|
||||
max_age: 3600
|
||||
paths:
|
||||
'^/api/':
|
||||
allow_origin: ['*']
|
||||
allow_headers: ['*']
|
||||
allow_methods: ['POST', 'PUT', 'GET', 'DELETE', 'OPTIONS']
|
||||
max_age: 3600
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
```php
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
|
||||
class RateLimitingService
|
||||
{
|
||||
public function __construct(
|
||||
private RateLimiterFactory $apiLimiter,
|
||||
private RateLimiterFactory $loginLimiter
|
||||
) {}
|
||||
|
||||
public function checkApiLimit(string $apiKey): void
|
||||
{
|
||||
$limiter = $this->apiLimiter->create($apiKey);
|
||||
|
||||
if (!$limiter->consume(1)->isAccepted()) {
|
||||
throw new TooManyRequestsHttpException(
|
||||
$limiter->getRetryAfter()->getTimestamp() - time(),
|
||||
'API rate limit exceeded'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkLoginLimit(string $username, string $ip): void
|
||||
{
|
||||
$limiter = $this->loginLimiter->create($username . '_' . $ip);
|
||||
|
||||
if (!$limiter->consume(1)->isAccepted()) {
|
||||
throw new TooManyRequestsHttpException(
|
||||
$limiter->getRetryAfter()->getTimestamp() - time(),
|
||||
'Too many login attempts'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// config/packages/rate_limiter.yaml
|
||||
framework:
|
||||
rate_limiter:
|
||||
api:
|
||||
policy: 'sliding_window'
|
||||
limit: 100
|
||||
interval: '60 minutes'
|
||||
login:
|
||||
policy: 'fixed_window'
|
||||
limit: 5
|
||||
interval: '15 minutes'
|
||||
```
|
||||
|
||||
## Encryption & Hashing
|
||||
|
||||
### Field-level Encryption
|
||||
|
||||
```php
|
||||
namespace App\Security;
|
||||
|
||||
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
|
||||
|
||||
class EncryptionService
|
||||
{
|
||||
private string $key;
|
||||
|
||||
public function __construct(string $encryptionKey)
|
||||
{
|
||||
$this->key = $encryptionKey;
|
||||
}
|
||||
|
||||
public function encrypt(string $data): string
|
||||
{
|
||||
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
|
||||
$encrypted = openssl_encrypt($data, 'aes-256-cbc', $this->key, 0, $iv);
|
||||
|
||||
return base64_encode($encrypted . '::' . $iv);
|
||||
}
|
||||
|
||||
public function decrypt(string $data): string
|
||||
{
|
||||
list($encrypted_data, $iv) = explode('::', base64_decode($data), 2);
|
||||
|
||||
return openssl_decrypt($encrypted_data, 'aes-256-cbc', $this->key, 0, $iv);
|
||||
}
|
||||
}
|
||||
|
||||
// Doctrine Type for encrypted fields
|
||||
class EncryptedStringType extends Type
|
||||
{
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform)
|
||||
{
|
||||
return $this->encryptionService->encrypt($value);
|
||||
}
|
||||
|
||||
public function convertToPHPValue($value, AbstractPlatform $platform)
|
||||
{
|
||||
return $this->encryptionService->decrypt($value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Always use HTTPS in production**
|
||||
2. **Implement CSRF protection for forms**
|
||||
3. **Use parameterized queries to prevent SQL injection**
|
||||
4. **Validate and sanitize all user input**
|
||||
5. **Implement proper session management**
|
||||
6. **Use strong password policies**
|
||||
7. **Implement account lockout mechanisms**
|
||||
8. **Log security events**
|
||||
9. **Regular security audits**
|
||||
10. **Keep dependencies updated**
|
||||
811
skills/symfony-skill/references/testing-complete.md
Normal file
811
skills/symfony-skill/references/testing-complete.md
Normal file
@@ -0,0 +1,811 @@
|
||||
# Symfony Testing Complete Guide
|
||||
|
||||
## Test Environment Setup
|
||||
|
||||
### Configuration
|
||||
|
||||
```yaml
|
||||
# config/packages/test/framework.yaml
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
|
||||
# config/packages/test/doctrine.yaml
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL_TEST)%'
|
||||
```
|
||||
|
||||
### Test Database Setup
|
||||
|
||||
```bash
|
||||
# Create test database
|
||||
php bin/console doctrine:database:create --env=test
|
||||
|
||||
# Run migrations
|
||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||
|
||||
# Load fixtures
|
||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||
```
|
||||
|
||||
## Unit Testing
|
||||
|
||||
### Testing Services
|
||||
|
||||
```php
|
||||
namespace App\Tests\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 testCalculatePrice(): void
|
||||
{
|
||||
$result = $this->calculator->calculate(100, 0.2, 10);
|
||||
|
||||
$this->assertEquals(110, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider priceProvider
|
||||
*/
|
||||
public function testCalculatePriceWithDataProvider(
|
||||
float $basePrice,
|
||||
float $tax,
|
||||
float $discount,
|
||||
float $expected
|
||||
): void {
|
||||
$result = $this->calculator->calculate($basePrice, $tax, $discount);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public function priceProvider(): array
|
||||
{
|
||||
return [
|
||||
'with tax and discount' => [100, 0.2, 10, 110],
|
||||
'no tax' => [100, 0, 10, 90],
|
||||
'no discount' => [100, 0.2, 0, 120],
|
||||
'zero price' => [0, 0.2, 10, 0],
|
||||
];
|
||||
}
|
||||
|
||||
public function testCalculatePriceThrowsExceptionForNegativePrice(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Price cannot be negative');
|
||||
|
||||
$this->calculator->calculate(-10, 0.2, 0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Entities
|
||||
|
||||
```php
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Category;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ProductTest extends TestCase
|
||||
{
|
||||
public function testGettersAndSetters(): void
|
||||
{
|
||||
$product = new Product();
|
||||
|
||||
$product->setName('Test Product');
|
||||
$this->assertEquals('Test Product', $product->getName());
|
||||
|
||||
$product->setPrice(99.99);
|
||||
$this->assertEquals(99.99, $product->getPrice());
|
||||
|
||||
$category = new Category();
|
||||
$category->setName('Electronics');
|
||||
|
||||
$product->setCategory($category);
|
||||
$this->assertSame($category, $product->getCategory());
|
||||
}
|
||||
|
||||
public function testProductValidation(): void
|
||||
{
|
||||
$product = new Product();
|
||||
|
||||
// Test required fields
|
||||
$this->assertNull($product->getName());
|
||||
|
||||
// Test default values
|
||||
$this->assertTrue($product->isActive());
|
||||
$this->assertEquals(0, $product->getStock());
|
||||
}
|
||||
|
||||
public function testProductSlugGeneration(): void
|
||||
{
|
||||
$product = new Product();
|
||||
$product->setName('Test Product Name');
|
||||
|
||||
$this->assertEquals('test-product-name', $product->getSlug());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Functional Testing
|
||||
|
||||
### Testing Controllers
|
||||
|
||||
```php
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use App\Repository\UserRepository;
|
||||
|
||||
class ProductControllerTest extends WebTestCase
|
||||
{
|
||||
public function testIndexPageIsSuccessful(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/products');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertSelectorTextContains('h1', 'Products');
|
||||
}
|
||||
|
||||
public function testShowProduct(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/products/1');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertSelectorExists('.product-details');
|
||||
}
|
||||
|
||||
public function testCreateProductRequiresAuthentication(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->request('GET', '/products/new');
|
||||
|
||||
$this->assertResponseRedirects('/login');
|
||||
}
|
||||
|
||||
public function testCreateProductAsAuthenticatedUser(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// Authenticate user
|
||||
$userRepository = static::getContainer()->get(UserRepository::class);
|
||||
$testUser = $userRepository->findOneByEmail('admin@example.com');
|
||||
$client->loginUser($testUser);
|
||||
|
||||
// Access protected page
|
||||
$crawler = $client->request('GET', '/products/new');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
// Fill and submit form
|
||||
$form = $crawler->selectButton('Save')->form([
|
||||
'product[name]' => 'Test Product',
|
||||
'product[price]' => '99.99',
|
||||
'product[description]' => 'Test description',
|
||||
]);
|
||||
|
||||
$client->submit($form);
|
||||
|
||||
$this->assertResponseRedirects('/products');
|
||||
|
||||
// Follow redirect
|
||||
$client->followRedirect();
|
||||
|
||||
$this->assertSelectorTextContains('.alert-success', 'Product created successfully');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Forms
|
||||
|
||||
```php
|
||||
namespace App\Tests\Form;
|
||||
|
||||
use App\Form\ProductType;
|
||||
use App\Entity\Product;
|
||||
use Symfony\Component\Form\Test\TypeTestCase;
|
||||
|
||||
class ProductTypeTest extends TypeTestCase
|
||||
{
|
||||
public function testSubmitValidData(): void
|
||||
{
|
||||
$formData = [
|
||||
'name' => 'Test Product',
|
||||
'price' => 99.99,
|
||||
'description' => 'Test description',
|
||||
'stock' => 10,
|
||||
];
|
||||
|
||||
$model = new Product();
|
||||
$form = $this->factory->create(ProductType::class, $model);
|
||||
|
||||
$expected = new Product();
|
||||
$expected->setName('Test Product');
|
||||
$expected->setPrice(99.99);
|
||||
$expected->setDescription('Test description');
|
||||
$expected->setStock(10);
|
||||
|
||||
$form->submit($formData);
|
||||
|
||||
$this->assertTrue($form->isSynchronized());
|
||||
$this->assertEquals($expected->getName(), $model->getName());
|
||||
$this->assertEquals($expected->getPrice(), $model->getPrice());
|
||||
|
||||
$view = $form->createView();
|
||||
$children = $view->children;
|
||||
|
||||
foreach (array_keys($formData) as $key) {
|
||||
$this->assertArrayHasKey($key, $children);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Testing
|
||||
|
||||
### Testing API Endpoints
|
||||
|
||||
```php
|
||||
namespace App\Tests\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
|
||||
use App\Entity\Product;
|
||||
|
||||
class ProductApiTest extends ApiTestCase
|
||||
{
|
||||
public function testGetCollection(): void
|
||||
{
|
||||
$response = static::createClient()->request('GET', '/api/products');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
|
||||
|
||||
$this->assertJsonContains([
|
||||
'@context' => '/api/contexts/Product',
|
||||
'@id' => '/api/products',
|
||||
'@type' => 'hydra:Collection',
|
||||
]);
|
||||
|
||||
$this->assertCount(30, $response->toArray()['hydra:member']);
|
||||
}
|
||||
|
||||
public function testCreateProduct(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
// Authenticate
|
||||
$token = $this->getToken($client);
|
||||
|
||||
$response = $client->request('POST', '/api/products', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'name' => 'New Product',
|
||||
'price' => 149.99,
|
||||
'description' => 'A new product',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
|
||||
$this->assertJsonContains([
|
||||
'@type' => 'Product',
|
||||
'name' => 'New Product',
|
||||
'price' => 149.99,
|
||||
]);
|
||||
}
|
||||
|
||||
public function testUpdateProduct(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$iri = $this->findIriBy(Product::class, ['id' => 1]);
|
||||
|
||||
$client->request('PUT', $iri, [
|
||||
'json' => [
|
||||
'price' => 199.99,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$this->assertJsonContains([
|
||||
'@id' => $iri,
|
||||
'price' => 199.99,
|
||||
]);
|
||||
}
|
||||
|
||||
public function testDeleteProduct(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$iri = $this->findIriBy(Product::class, ['id' => 1]);
|
||||
|
||||
$client->request('DELETE', $iri);
|
||||
|
||||
$this->assertResponseStatusCodeSame(204);
|
||||
|
||||
// Verify deletion
|
||||
$this->assertNull(
|
||||
static::getContainer()->get('doctrine')->getRepository(Product::class)->find(1)
|
||||
);
|
||||
}
|
||||
|
||||
private function getToken($client): string
|
||||
{
|
||||
$response = $client->request('POST', '/api/login', [
|
||||
'json' => [
|
||||
'email' => 'admin@example.com',
|
||||
'password' => 'password123',
|
||||
],
|
||||
]);
|
||||
|
||||
return $response->toArray()['token'];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Services with Database
|
||||
|
||||
```php
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Service\OrderService;
|
||||
use App\Entity\Order;
|
||||
use App\Entity\Product;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
class OrderServiceTest extends KernelTestCase
|
||||
{
|
||||
private ?OrderService $orderService;
|
||||
private ?\Doctrine\ORM\EntityManager $entityManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$kernel = self::bootKernel();
|
||||
|
||||
$this->entityManager = $kernel->getContainer()
|
||||
->get('doctrine')
|
||||
->getManager();
|
||||
|
||||
$this->orderService = $kernel->getContainer()
|
||||
->get(OrderService::class);
|
||||
}
|
||||
|
||||
public function testCreateOrder(): void
|
||||
{
|
||||
// Create test product
|
||||
$product = new Product();
|
||||
$product->setName('Test Product');
|
||||
$product->setPrice(99.99);
|
||||
$product->setStock(10);
|
||||
|
||||
$this->entityManager->persist($product);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Create order
|
||||
$order = $this->orderService->createOrder([
|
||||
'product_id' => $product->getId(),
|
||||
'quantity' => 2,
|
||||
]);
|
||||
|
||||
$this->assertInstanceOf(Order::class, $order);
|
||||
$this->assertEquals(199.98, $order->getTotal());
|
||||
$this->assertEquals(8, $product->getStock());
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
|
||||
$this->entityManager->close();
|
||||
$this->entityManager = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Doubles & Mocking
|
||||
|
||||
### Using Prophecy
|
||||
|
||||
```php
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\Service\EmailService;
|
||||
use App\Service\NotificationService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
|
||||
class NotificationServiceTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testSendNotification(): void
|
||||
{
|
||||
$emailService = $this->prophesize(EmailService::class);
|
||||
|
||||
$emailService->send(
|
||||
'user@example.com',
|
||||
'Notification',
|
||||
'You have a new notification'
|
||||
)->shouldBeCalledOnce();
|
||||
|
||||
$notificationService = new NotificationService($emailService->reveal());
|
||||
$notificationService->notify('user@example.com', 'You have a new notification');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using PHPUnit Mock
|
||||
|
||||
```php
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Repository\ProductRepository;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Query;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ProductRepositoryTest extends TestCase
|
||||
{
|
||||
public function testFindActiveProducts(): void
|
||||
{
|
||||
$query = $this->createMock(Query::class);
|
||||
$query->expects($this->once())
|
||||
->method('getResult')
|
||||
->willReturn(['product1', 'product2']);
|
||||
|
||||
$qb = $this->getMockBuilder(\Doctrine\ORM\QueryBuilder::class)
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
$qb->expects($this->once())
|
||||
->method('andWhere')
|
||||
->with('p.active = :active')
|
||||
->willReturnSelf();
|
||||
|
||||
$qb->expects($this->once())
|
||||
->method('setParameter')
|
||||
->with('active', true)
|
||||
->willReturnSelf();
|
||||
|
||||
$qb->expects($this->once())
|
||||
->method('getQuery')
|
||||
->willReturn($query);
|
||||
|
||||
$em = $this->createMock(EntityManager::class);
|
||||
$em->expects($this->once())
|
||||
->method('createQueryBuilder')
|
||||
->willReturn($qb);
|
||||
|
||||
$repository = new ProductRepository($em);
|
||||
$result = $repository->findActiveProducts();
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Data Fixtures
|
||||
|
||||
### Creating Fixtures
|
||||
|
||||
```php
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Entity\Product;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
class AppFixtures extends Fixture
|
||||
{
|
||||
public const ADMIN_USER_REFERENCE = 'admin-user';
|
||||
|
||||
public function __construct(
|
||||
private UserPasswordHasherInterface $passwordHasher
|
||||
) {}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
// Create admin user
|
||||
$admin = new User();
|
||||
$admin->setEmail('admin@example.com');
|
||||
$admin->setRoles(['ROLE_ADMIN']);
|
||||
$admin->setPassword(
|
||||
$this->passwordHasher->hashPassword($admin, 'password123')
|
||||
);
|
||||
|
||||
$manager->persist($admin);
|
||||
$this->addReference(self::ADMIN_USER_REFERENCE, $admin);
|
||||
|
||||
// Create products
|
||||
for ($i = 1; $i <= 20; $i++) {
|
||||
$product = new Product();
|
||||
$product->setName('Product ' . $i);
|
||||
$product->setPrice(mt_rand(10, 200));
|
||||
$product->setDescription('Description for product ' . $i);
|
||||
$product->setStock(mt_rand(0, 100));
|
||||
|
||||
$manager->persist($product);
|
||||
}
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fixture Dependencies
|
||||
|
||||
```php
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
|
||||
class OrderFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$user = $this->getReference(AppFixtures::ADMIN_USER_REFERENCE);
|
||||
|
||||
$order = new Order();
|
||||
$order->setUser($user);
|
||||
// ...
|
||||
|
||||
$manager->persist($order);
|
||||
$manager->flush();
|
||||
}
|
||||
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [
|
||||
AppFixtures::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Browser Testing with Panther
|
||||
|
||||
```php
|
||||
namespace App\Tests\E2E;
|
||||
|
||||
use Symfony\Component\Panther\PantherTestCase;
|
||||
|
||||
class E2ETest extends PantherTestCase
|
||||
{
|
||||
public function testHomePage(): void
|
||||
{
|
||||
$client = static::createPantherClient();
|
||||
$crawler = $client->request('GET', '/');
|
||||
|
||||
$this->assertSelectorTextContains('h1', 'Welcome');
|
||||
|
||||
// Take screenshot
|
||||
$client->takeScreenshot('tests/screenshots/homepage.png');
|
||||
}
|
||||
|
||||
public function testJavaScriptInteraction(): void
|
||||
{
|
||||
$client = static::createPantherClient();
|
||||
$crawler = $client->request('GET', '/interactive');
|
||||
|
||||
// Click button that triggers JavaScript
|
||||
$client->clickLink('Load More');
|
||||
|
||||
// Wait for AJAX content
|
||||
$client->waitFor('.loaded-content');
|
||||
|
||||
$this->assertSelectorExists('.loaded-content');
|
||||
$this->assertSelectorTextContains('.loaded-content', 'Dynamic content');
|
||||
}
|
||||
|
||||
public function testFormSubmission(): void
|
||||
{
|
||||
$client = static::createPantherClient();
|
||||
$crawler = $client->request('GET', '/contact');
|
||||
|
||||
// Fill form
|
||||
$crawler->filter('#contact_name')->sendKeys('John Doe');
|
||||
$crawler->filter('#contact_email')->sendKeys('john@example.com');
|
||||
$crawler->filter('#contact_message')->sendKeys('Test message');
|
||||
|
||||
// Submit
|
||||
$crawler->filter('#contact_submit')->click();
|
||||
|
||||
// Wait for response
|
||||
$client->waitFor('.alert-success');
|
||||
|
||||
$this->assertSelectorTextContains('.alert-success', 'Message sent successfully');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
```php
|
||||
namespace App\Tests\Performance;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
class PerformanceTest extends WebTestCase
|
||||
{
|
||||
public function testPageLoadTime(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$startTime = microtime(true);
|
||||
$client->request('GET', '/products');
|
||||
$endTime = microtime(true);
|
||||
|
||||
$loadTime = $endTime - $startTime;
|
||||
|
||||
$this->assertLessThan(1, $loadTime, 'Page should load in less than 1 second');
|
||||
}
|
||||
|
||||
public function testDatabaseQueryCount(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->enableProfiler();
|
||||
|
||||
$client->request('GET', '/products');
|
||||
|
||||
if ($profile = $client->getProfile()) {
|
||||
$collector = $profile->getCollector('db');
|
||||
|
||||
$this->assertLessThan(10, $collector->getQueryCount(),
|
||||
'Page should execute less than 10 queries');
|
||||
}
|
||||
}
|
||||
|
||||
public function testMemoryUsage(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$client->enableProfiler();
|
||||
|
||||
$client->request('GET', '/products');
|
||||
|
||||
if ($profile = $client->getProfile()) {
|
||||
$collector = $profile->getCollector('memory');
|
||||
|
||||
// Memory usage should be less than 50MB
|
||||
$this->assertLessThan(50 * 1024 * 1024, $collector->getMemory());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Commands
|
||||
|
||||
```php
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class ImportCommandTest extends KernelTestCase
|
||||
{
|
||||
public function testExecute(): void
|
||||
{
|
||||
$kernel = static::createKernel();
|
||||
$application = new Application($kernel);
|
||||
|
||||
$command = $application->find('app:import-products');
|
||||
$commandTester = new CommandTester($command);
|
||||
|
||||
$commandTester->execute([
|
||||
'file' => 'tests/fixtures/products.csv',
|
||||
'--dry-run' => true,
|
||||
]);
|
||||
|
||||
$commandTester->assertCommandIsSuccessful();
|
||||
|
||||
$output = $commandTester->getDisplay();
|
||||
$this->assertStringContainsString('Import completed', $output);
|
||||
$this->assertStringContainsString('10 products imported', $output);
|
||||
}
|
||||
|
||||
public function testExecuteWithInvalidFile(): void
|
||||
{
|
||||
$kernel = static::createKernel();
|
||||
$application = new Application($kernel);
|
||||
|
||||
$command = $application->find('app:import-products');
|
||||
$commandTester = new CommandTester($command);
|
||||
|
||||
$commandTester->execute([
|
||||
'file' => 'nonexistent.csv',
|
||||
]);
|
||||
|
||||
$this->assertEquals(1, $commandTester->getStatusCode());
|
||||
$this->assertStringContainsString('File not found', $commandTester->getDisplay());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Code Coverage
|
||||
|
||||
### PHPUnit Configuration
|
||||
|
||||
```xml
|
||||
<!-- phpunit.xml.dist -->
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
backupGlobals="false"
|
||||
colors="true"
|
||||
bootstrap="tests/bootstrap.php">
|
||||
|
||||
<php>
|
||||
<ini name="display_errors" value="1" />
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<env name="APP_ENV" value="test" force="true" />
|
||||
<env name="SHELL_VERBOSITY" value="-1" />
|
||||
<env name="SYMFONY_PHPUNIT_REMOVE" value="" />
|
||||
<env name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
<testsuite name="Project Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<coverage processUncoveredFiles="true">
|
||||
<include>
|
||||
<directory suffix=".php">src</directory>
|
||||
</include>
|
||||
<exclude>
|
||||
<directory>src/Kernel.php</directory>
|
||||
<directory>src/DataFixtures</directory>
|
||||
</exclude>
|
||||
</coverage>
|
||||
|
||||
<listeners>
|
||||
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
|
||||
</listeners>
|
||||
</phpunit>
|
||||
```
|
||||
|
||||
### Running Tests with Coverage
|
||||
|
||||
```bash
|
||||
# Generate HTML coverage report
|
||||
php bin/phpunit --coverage-html coverage
|
||||
|
||||
# Generate text coverage in console
|
||||
php bin/phpunit --coverage-text
|
||||
|
||||
# Generate Clover XML for CI
|
||||
php bin/phpunit --coverage-clover coverage.xml
|
||||
```
|
||||
|
||||
## Test Best Practices
|
||||
|
||||
1. **Follow AAA pattern**: Arrange, Act, Assert
|
||||
2. **One assertion per test method** when possible
|
||||
3. **Use descriptive test names** that explain what is being tested
|
||||
4. **Test edge cases** and error conditions
|
||||
5. **Mock external dependencies** in unit tests
|
||||
6. **Use fixtures** for consistent test data
|
||||
7. **Keep tests independent** - each test should be able to run alone
|
||||
8. **Test public API only** - don't test private methods directly
|
||||
9. **Use data providers** for testing multiple scenarios
|
||||
10. **Write tests first** (TDD) for critical business logic
|
||||
Reference in New Issue
Block a user