Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 17:59:04 +08:00
commit cb040b113c
17 changed files with 5799 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Symfony Framework Development Skill
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,222 @@
# Symfony Framework Development Skill
## Overview
This skill provides comprehensive guidance and tools for developing applications with Symfony 6.4, the leading PHP framework for web applications, APIs, and microservices.
## Features
- **Complete Symfony 6.4 Documentation**: Core concepts, best practices, and advanced patterns
- **Code Generation Scripts**: Automated tools for entity and CRUD generation
- **Deployment Automation**: Production-ready deployment scripts
- **Security Configurations**: Advanced authentication and authorization patterns
- **Performance Optimization**: Caching strategies and query optimization techniques
- **Testing Strategies**: Unit, functional, and integration testing patterns
## Structure
```
symfony-skill/
├── SKILL.md # Main skill file with core instructions
├── README.md # This file
├── LICENSE # MIT License
├── scripts/ # Automation scripts
│ ├── generate-entity.php # Entity generator
│ ├── generate-crud.php # CRUD generator
│ └── deploy.sh # Deployment automation
└── references/ # Detailed documentation
├── doctrine-advanced.md # Advanced ORM patterns
├── security-detailed.md # Security configurations
└── testing-complete.md # Testing strategies
```
## Quick Start
### Using the Skill
1. **Load the skill** in your Claude conversation
2. **Reference specific topics** when needed
3. **Use scripts** for code generation tasks
4. **Check references** for detailed documentation
### Common Commands
```bash
# Create new Symfony project
symfony new my_project --version="6.4.*" --webapp
# Generate entity
php scripts/generate-entity.php Product name:string price:decimal
# Generate CRUD
php scripts/generate-crud.php Product
# Deploy application
./scripts/deploy.sh production main
```
## Key Capabilities
### 1. Project Setup & Configuration
- Environment configuration
- Service container setup
- Bundle management
- Package installation
### 2. Development Patterns
- MVC architecture
- Dependency injection
- Event-driven programming
- Repository pattern
- Service layer pattern
### 3. Database & ORM
- Entity management
- Relationships mapping
- Query optimization
- Migration strategies
- Performance tuning
### 4. Security
- Authentication methods
- Authorization with voters
- JWT tokens
- OAuth integration
- Two-factor authentication
### 5. API Development
- RESTful APIs
- GraphQL integration
- API versioning
- Rate limiting
- CORS configuration
### 6. Testing
- Unit testing
- Functional testing
- Integration testing
- Test fixtures
- Mocking strategies
### 7. Performance
- Caching strategies
- Query optimization
- Asset optimization
- Profiling tools
- Production optimization
### 8. Deployment
- Environment preparation
- Automated deployment
- Zero-downtime deployment
- Rollback procedures
- Health checks
## Usage Examples
### Creating a New Feature
1. **Generate the entity**:
```bash
php scripts/generate-entity.php Article title:string content:text author:relation:ManyToOne:User
```
2. **Create the CRUD**:
```bash
php scripts/generate-crud.php Article
```
3. **Add business logic** in services
4. **Write tests** for the feature
5. **Deploy** using the deployment script
### API Development
1. **Create API controller** using the --api flag:
```bash
php scripts/generate-crud.php Product --api
```
2. **Configure serialization groups** in your entity
3. **Add validation rules**
4. **Implement authentication**
5. **Document with OpenAPI/Swagger**
## Best Practices
1. **Always use dependency injection** - Never instantiate services manually
2. **Keep controllers thin** - Move business logic to services
3. **Write tests first** - Follow TDD approach for critical features
4. **Cache expensive operations** - Use Symfony's cache component
5. **Optimize database queries** - Use eager loading and query optimization
6. **Follow Symfony conventions** - Use recommended directory structure and naming
7. **Use environment variables** - For configuration that changes between environments
8. **Implement proper error handling** - Use exceptions and proper HTTP status codes
9. **Document your code** - Use PHPDoc and keep documentation updated
10. **Regular security updates** - Keep dependencies updated and check for vulnerabilities
## Common Issues & Solutions
### Issue: Services not autowiring
**Solution**: Check service configuration and ensure autowiring is enabled
### Issue: Database connection errors
**Solution**: Verify DATABASE_URL in .env file and database credentials
### Issue: Cache permissions
**Solution**: Set proper permissions on var/cache directory
### Issue: Route not found
**Solution**: Clear cache and check route configuration with debug:router
### Issue: Migration failures
**Solution**: Check migration status and resolve conflicts manually if needed
## Requirements
- PHP 8.1 or higher
- Composer
- Symfony CLI (recommended)
- Database (MySQL/PostgreSQL/SQLite)
- Web server (Apache/Nginx)
- Node.js & NPM (for assets)
## Compatibility
- Symfony 6.4 LTS
- PHP 8.1, 8.2, 8.3
- MySQL 5.7+, PostgreSQL 10+, SQLite 3+
- Compatible with Docker environments
## Contributing
This skill is designed to be extended. To add new capabilities:
1. Update SKILL.md with new patterns
2. Add scripts to scripts/ directory
3. Create reference documentation in references/
4. Test thoroughly before deployment
## Support
For Symfony-specific questions:
- Official Documentation: https://symfony.com/doc/6.4/
- Symfony Slack: https://symfony.com/slack
- Stack Overflow: Tag with `symfony`
## Version History
- **1.0.0** - Initial release with Symfony 6.4 support
- Complete documentation coverage
- Entity and CRUD generators
- Deployment automation
- Advanced patterns documentation
## License
MIT License - See LICENSE file for details
## Credits
Created for Claude AI to enhance Symfony development capabilities.
Based on official Symfony 6.4 documentation and community best practices.

View File

@@ -0,0 +1,496 @@
---
name: symfony-framework
description: Comprehensive Symfony 6.4 development skill for creating web applications, APIs, and microservices. Provides workflows, best practices, and tools for efficient Symfony development including controllers, routing, database operations with Doctrine, forms, security, testing, and deployment.
license: MIT
version: 1.0.0
---
# Symfony Framework Development Skill
This skill provides comprehensive guidance for developing applications with Symfony 6.4, the leading PHP framework for web applications and APIs.
## Quick Start Workflow
### 1. Project Initialization
For a new Symfony project:
```bash
# Full web application
symfony new project_name --version="6.4.*" --webapp
# API/Microservice
symfony new project_name --version="6.4.*"
# If Symfony CLI not available, use Composer
composer create-project symfony/skeleton:"6.4.*" project_name
```
### 2. Development Server
```bash
symfony server:start
# Access at http://localhost:8000
```
## Core Development Patterns
### Controller Creation
Always extend `AbstractController` and use attributes for routing:
```php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ProductController extends AbstractController
{
#[Route('/products', name: 'product_list')]
public function list(): Response
{
return $this->render('product/list.html.twig', [
'products' => $products,
]);
}
#[Route('/products/{id}', name: 'product_show')]
public function show(Product $product): Response
{
// Automatic entity parameter conversion
return $this->render('product/show.html.twig', [
'product' => $product,
]);
}
}
```
### Service Configuration
Use autowiring by default in `config/services.yaml`:
```yaml
services:
_defaults:
autowire: true
autoconfigure: true
public: false
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
```
### Database Operations with Doctrine
#### Entity Creation
```bash
php bin/console make:entity Product
```
#### Migration Workflow
```bash
# Generate migration
php bin/console make:migration
# Execute migration
php bin/console doctrine:migrations:migrate
```
#### Repository Pattern
```php
// In controller
public function index(ProductRepository $repository): Response
{
$products = $repository->findBy(['active' => true]);
// Custom repository methods
$featured = $repository->findFeaturedProducts();
}
```
### Form Handling
Build forms in dedicated classes:
```php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProductType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name')
->add('price')
->add('description')
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Product::class,
]);
}
}
```
Controller handling:
```php
#[Route('/product/new', name: 'product_new')]
public function new(Request $request, EntityManagerInterface $em): Response
{
$product = new Product();
$form = $this->createForm(ProductType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($product);
$em->flush();
return $this->redirectToRoute('product_show', ['id' => $product->getId()]);
}
return $this->render('product/new.html.twig', [
'form' => $form,
]);
}
```
### Security Implementation
#### User Authentication
```yaml
# config/packages/security.yaml
security:
password_hashers:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
main:
form_login:
login_path: app_login
check_path: app_login
logout:
path: app_logout
```
#### Authorization with Voters
```php
namespace App\Security\Voter;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ProductVoter extends Voter
{
public const EDIT = 'PRODUCT_EDIT';
public const VIEW = 'PRODUCT_VIEW';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::VIEW])
&& $subject instanceof Product;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
return match($attribute) {
self::VIEW => true,
self::EDIT => $user && $subject->getOwner() === $user,
default => false,
};
}
}
```
### API Development
#### JSON Responses
```php
#[Route('/api/products', name: 'api_products')]
public function apiList(ProductRepository $repository): JsonResponse
{
$products = $repository->findAll();
return $this->json($products, 200, [], [
'groups' => ['product:read']
]);
}
```
#### Request Validation
```php
#[Route('/api/product', methods: ['POST'])]
public function create(
#[MapRequestPayload] ProductDto $dto,
ValidatorInterface $validator
): JsonResponse {
$errors = $validator->validate($dto);
if (count($errors) > 0) {
return $this->json($errors, 400);
}
// Process valid data
}
```
## Essential Commands
### Development
```bash
# Clear cache
php bin/console cache:clear
# Show routes
php bin/console debug:router
# Show services
php bin/console debug:container
# Show configuration
php bin/console debug:config framework
# Create controller
php bin/console make:controller
# Create CRUD
php bin/console make:crud Product
```
### Database
```bash
# Create database
php bin/console doctrine:database:create
# Update schema (dev only)
php bin/console doctrine:schema:update --force
# Load fixtures
php bin/console doctrine:fixtures:load
```
### Testing
```bash
# Run all tests
php bin/phpunit
# Specific test file
php bin/phpunit tests/Controller/ProductControllerTest.php
# With coverage
php bin/phpunit --coverage-html coverage/
```
## Directory Structure
```
project/
├── assets/ # Frontend assets (JS, CSS)
├── bin/ # Executables (console, phpunit)
├── config/ # Configuration files
│ ├── packages/ # Package-specific config
│ ├── routes/ # Routing configuration
│ └── services.yaml # Service definitions
├── migrations/ # Database migrations
├── public/ # Web root
│ └── index.php # Front controller
├── src/ # Application code
│ ├── Controller/ # Controllers
│ ├── Entity/ # Doctrine entities
│ ├── Form/ # Form types
│ ├── Repository/ # Doctrine repositories
│ └── Service/ # Business logic
├── templates/ # Twig templates
├── tests/ # Test suites
├── translations/ # Translation files
├── var/ # Generated files (cache, logs)
└── vendor/ # Dependencies
```
## Performance Optimization
### Caching Strategy
```yaml
# config/packages/cache.yaml
framework:
cache:
pools:
cache.product:
adapter: cache.adapter.redis
default_lifetime: 3600
```
Usage:
```php
public function __construct(private CacheInterface $productCache) {}
public function getProduct(int $id): ?Product
{
return $this->productCache->get(
'product_' . $id,
function (ItemInterface $item) use ($id) {
$item->expiresAfter(3600);
return $this->repository->find($id);
}
);
}
```
### Query Optimization
```php
// Eager loading with Doctrine
$products = $repository->createQueryBuilder('p')
->leftJoin('p.category', 'c')
->addSelect('c')
->leftJoin('p.tags', 't')
->addSelect('t')
->getQuery()
->getResult();
```
## Error Handling
```php
// In controller
if (!$product) {
throw $this->createNotFoundException('Product not found');
}
// Custom exception
throw new BadRequestHttpException('Invalid product data');
// API error response
return $this->json([
'error' => 'Invalid request',
'details' => $errors
], Response::HTTP_BAD_REQUEST);
```
## Deployment Checklist
1. **Environment setup**
```bash
APP_ENV=prod
APP_DEBUG=0
```
2. **Optimize autoloader**
```bash
composer install --no-dev --optimize-autoloader
```
3. **Clear and warm cache**
```bash
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
```
4. **Compile assets**
```bash
npm run build
```
5. **Run migrations**
```bash
php bin/console doctrine:migrations:migrate --env=prod
```
## Testing Patterns
### Functional Tests
```php
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ProductControllerTest extends WebTestCase
{
public function testListProducts(): void
{
$client = static::createClient();
$client->request('GET', '/products');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Products');
}
public function testCreateProduct(): void
{
$client = static::createClient();
$client->request('POST', '/api/products', [], [], [
'CONTENT_TYPE' => 'application/json'
], json_encode(['name' => 'Test Product']));
$this->assertResponseStatusCodeSame(201);
}
}
```
## Common Patterns to Follow
1. **Always use dependency injection** - Never instantiate services manually
2. **Prefer composition over inheritance** - Use services and traits
3. **Keep controllers thin** - Move business logic to services
4. **Use DTOs for API input/output** - Decouple API from entities
5. **Implement repository pattern** - Keep database queries in repositories
6. **Use voters for authorization** - Centralize access control logic
7. **Cache expensive operations** - Use Symfony's cache component
8. **Write tests first** - TDD approach for critical features
## Troubleshooting Guide
### Common Issues
**Issue**: Services not autowiring
```bash
php bin/console debug:container ServiceName
# Check if service is properly registered
```
**Issue**: Route not found
```bash
php bin/console debug:router | grep pattern
# Verify route registration
```
**Issue**: Database connection errors
```bash
php bin/console doctrine:database:create
# Verify database credentials in .env
```
**Issue**: Template not found
- Check template path relative to `templates/`
- Verify file extension is `.html.twig`
## When to Use Scripts
Refer to bundled scripts in `scripts/` for:
- Complex entity generation
- Database migration helpers
- Deployment automation
- Performance profiling
## Additional Resources
For detailed documentation on specific topics, load the appropriate reference file from `references/`:
- `references/doctrine-advanced.md` - Complex ORM patterns
- `references/security-detailed.md` - Advanced security configurations
- `references/api-platform.md` - API Platform integration
- `references/testing-complete.md` - Comprehensive testing strategies
- `references/performance-tuning.md` - Performance optimization techniques

View 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
}
```

View 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

View 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**

View 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**

View 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

View File

@@ -0,0 +1,352 @@
#!/bin/bash
# Symfony Deployment Script
# Usage: ./deploy.sh [environment] [branch]
# Example: ./deploy.sh production main
set -e
# Configuration
ENVIRONMENT=${1:-production}
BRANCH=${2:-main}
PROJECT_PATH="/var/www/symfony-app"
BACKUP_PATH="/var/backups/symfony-app"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Functions
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
check_requirements() {
log_info "Checking requirements..."
# Check if PHP is installed
if ! command -v php &> /dev/null; then
log_error "PHP is not installed"
exit 1
fi
# Check if Composer is installed
if ! command -v composer &> /dev/null; then
log_error "Composer is not installed"
exit 1
fi
# Check if Git is installed
if ! command -v git &> /dev/null; then
log_error "Git is not installed"
exit 1
fi
# Check PHP version
PHP_VERSION=$(php -r "echo PHP_VERSION;")
MIN_PHP_VERSION="8.1.0"
if [ "$(printf '%s\n' "$MIN_PHP_VERSION" "$PHP_VERSION" | sort -V | head -n1)" != "$MIN_PHP_VERSION" ]; then
log_error "PHP version must be at least $MIN_PHP_VERSION (current: $PHP_VERSION)"
exit 1
fi
log_info "All requirements met"
}
create_backup() {
log_info "Creating backup..."
# Create backup directory if it doesn't exist
mkdir -p "$BACKUP_PATH"
# Backup database
if [ -f "$PROJECT_PATH/.env.local" ]; then
source "$PROJECT_PATH/.env.local"
if [ ! -z "$DATABASE_URL" ]; then
# Parse database URL
DB_USER=$(echo $DATABASE_URL | sed -E 's/.*:\/\/([^:]+):.*/\1/')
DB_PASS=$(echo $DATABASE_URL | sed -E 's/.*:\/\/[^:]+:([^@]+)@.*/\1/')
DB_HOST=$(echo $DATABASE_URL | sed -E 's/.*@([^:\/]+).*/\1/')
DB_NAME=$(echo $DATABASE_URL | sed -E 's/.*\///')
mysqldump -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" > "$BACKUP_PATH/db_backup_$TIMESTAMP.sql"
log_info "Database backup created: db_backup_$TIMESTAMP.sql"
fi
fi
# Backup files
tar -czf "$BACKUP_PATH/files_backup_$TIMESTAMP.tar.gz" \
-C "$PROJECT_PATH" \
--exclude=var/cache \
--exclude=var/log \
--exclude=vendor \
--exclude=node_modules \
.
log_info "Files backup created: files_backup_$TIMESTAMP.tar.gz"
# Keep only last 5 backups
ls -1dt "$BACKUP_PATH"/* | tail -n +11 | xargs -r rm -f
}
pull_latest_code() {
log_info "Pulling latest code from $BRANCH branch..."
cd "$PROJECT_PATH"
# Stash any local changes
git stash
# Fetch latest changes
git fetch origin
# Checkout and pull the specified branch
git checkout "$BRANCH"
git pull origin "$BRANCH"
log_info "Code updated to latest version"
}
install_dependencies() {
log_info "Installing dependencies..."
cd "$PROJECT_PATH"
# Install Composer dependencies
if [ "$ENVIRONMENT" = "production" ]; then
composer install --no-dev --optimize-autoloader --no-interaction
else
composer install --optimize-autoloader --no-interaction
fi
# Install NPM dependencies if package.json exists
if [ -f "package.json" ]; then
npm ci
# Build assets
if [ "$ENVIRONMENT" = "production" ]; then
npm run build
else
npm run dev
fi
fi
log_info "Dependencies installed"
}
run_migrations() {
log_info "Running database migrations..."
cd "$PROJECT_PATH"
# Check if there are pending migrations
PENDING=$(php bin/console doctrine:migrations:status --show-versions | grep "not migrated" | wc -l)
if [ "$PENDING" -gt 0 ]; then
log_info "Found $PENDING pending migration(s)"
# Run migrations
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
log_info "Migrations completed"
else
log_info "No pending migrations"
fi
}
clear_cache() {
log_info "Clearing cache..."
cd "$PROJECT_PATH"
# Clear Symfony cache
php bin/console cache:clear --env="$ENVIRONMENT" --no-warmup
php bin/console cache:warmup --env="$ENVIRONMENT"
# Clear OPcache if available
if command -v cachetool &> /dev/null; then
cachetool opcache:reset
log_info "OPcache cleared"
fi
log_info "Cache cleared and warmed up"
}
update_permissions() {
log_info "Updating file permissions..."
cd "$PROJECT_PATH"
# Set proper permissions for var directory
chmod -R 775 var/
# Set proper ownership (adjust user:group as needed)
if [ ! -z "$WEB_USER" ]; then
chown -R "$WEB_USER":"$WEB_USER" var/
fi
log_info "Permissions updated"
}
run_tests() {
log_info "Running tests..."
cd "$PROJECT_PATH"
# Run PHPUnit tests if they exist
if [ -f "bin/phpunit" ] || [ -f "vendor/bin/phpunit" ]; then
if [ "$ENVIRONMENT" != "production" ]; then
php bin/phpunit --testdox || {
log_error "Tests failed! Deployment aborted."
exit 1
}
log_info "All tests passed"
else
log_warning "Skipping tests in production environment"
fi
else
log_warning "PHPUnit not found, skipping tests"
fi
}
restart_services() {
log_info "Restarting services..."
# Restart PHP-FPM
if systemctl is-active --quiet php8.1-fpm; then
systemctl reload php8.1-fpm
log_info "PHP-FPM reloaded"
fi
# Restart web server
if systemctl is-active --quiet nginx; then
systemctl reload nginx
log_info "Nginx reloaded"
elif systemctl is-active --quiet apache2; then
systemctl reload apache2
log_info "Apache reloaded"
fi
# Restart queue workers if Messenger is used
if systemctl is-active --quiet symfony-messenger; then
systemctl restart symfony-messenger
log_info "Messenger workers restarted"
fi
}
health_check() {
log_info "Performing health check..."
cd "$PROJECT_PATH"
# Check if the application responds
if [ ! -z "$APP_URL" ]; then
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/health-check")
if [ "$HTTP_STATUS" -eq 200 ]; then
log_info "Health check passed (HTTP $HTTP_STATUS)"
else
log_error "Health check failed (HTTP $HTTP_STATUS)"
exit 1
fi
fi
# Check database connection
php bin/console doctrine:query:sql "SELECT 1" > /dev/null 2>&1 || {
log_error "Database connection failed"
exit 1
}
log_info "All health checks passed"
}
notify_deployment() {
MESSAGE="$1"
# Send notification (configure your notification method)
# Example: Slack webhook
if [ ! -z "$SLACK_WEBHOOK" ]; then
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"Deployment: $MESSAGE\"}" \
"$SLACK_WEBHOOK"
fi
# Log to deployment log
echo "[$(date)] $MESSAGE" >> "$PROJECT_PATH/var/log/deployments.log"
}
rollback() {
log_error "Deployment failed! Rolling back..."
# Restore database from backup
if [ -f "$BACKUP_PATH/db_backup_$TIMESTAMP.sql" ]; then
mysql -h "$DB_HOST" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$BACKUP_PATH/db_backup_$TIMESTAMP.sql"
log_info "Database restored from backup"
fi
# Restore files from backup
if [ -f "$BACKUP_PATH/files_backup_$TIMESTAMP.tar.gz" ]; then
rm -rf "$PROJECT_PATH"/*
tar -xzf "$BACKUP_PATH/files_backup_$TIMESTAMP.tar.gz" -C "$PROJECT_PATH"
log_info "Files restored from backup"
fi
clear_cache
restart_services
notify_deployment "❌ Deployment failed and rolled back on $ENVIRONMENT"
exit 1
}
# Main deployment process
main() {
log_info "========================================="
log_info "Starting Symfony deployment"
log_info "Environment: $ENVIRONMENT"
log_info "Branch: $BRANCH"
log_info "Timestamp: $TIMESTAMP"
log_info "========================================="
# Set error trap
trap rollback ERR
# Execute deployment steps
check_requirements
create_backup
pull_latest_code
install_dependencies
run_migrations
clear_cache
update_permissions
run_tests
restart_services
health_check
# Remove error trap after successful deployment
trap - ERR
log_info "========================================="
log_info "Deployment completed successfully!"
log_info "========================================="
notify_deployment "✅ Successfully deployed to $ENVIRONMENT from $BRANCH"
}
# Run main function
main
# Exit successfully
exit 0

View File

@@ -0,0 +1,487 @@
#!/usr/bin/env php
<?php
/**
* Symfony CRUD Generator Script
*
* Usage: php generate-crud.php EntityName [--api]
* Example: php generate-crud.php Product
*/
if ($argc < 2) {
echo "Usage: php generate-crud.php EntityName [--api]\n";
exit(1);
}
$entityName = $argv[1];
$isApi = in_array('--api', $argv);
$entityLower = strtolower($entityName);
$entityPlural = $entityLower . 's';
// Generate Controller
if (!$isApi) {
$controllerCode = "<?php
namespace App\Controller;
use App\Entity\\$entityName;
use App\Form\\{$entityName}Type;
use App\Repository\\{$entityName}Repository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/$entityPlural')]
class {$entityName}Controller extends AbstractController
{
#[Route('/', name: '{$entityLower}_index', methods: ['GET'])]
public function index({$entityName}Repository \$repository): Response
{
return \$this->render('{$entityLower}/index.html.twig', [
'{$entityPlural}' => \$repository->findAll(),
]);
}
#[Route('/new', name: '{$entityLower}_new', methods: ['GET', 'POST'])]
public function new(Request \$request, EntityManagerInterface \$entityManager): Response
{
\${$entityLower} = new $entityName();
\$form = \$this->createForm({$entityName}Type::class, \${$entityLower});
\$form->handleRequest(\$request);
if (\$form->isSubmitted() && \$form->isValid()) {
\$entityManager->persist(\${$entityLower});
\$entityManager->flush();
\$this->addFlash('success', '$entityName created successfully!');
return \$this->redirectToRoute('{$entityLower}_show', ['id' => \${$entityLower}->getId()]);
}
return \$this->render('{$entityLower}/new.html.twig', [
'{$entityLower}' => \${$entityLower},
'form' => \$form,
]);
}
#[Route('/{id}', name: '{$entityLower}_show', methods: ['GET'])]
public function show($entityName \${$entityLower}): Response
{
return \$this->render('{$entityLower}/show.html.twig', [
'{$entityLower}' => \${$entityLower},
]);
}
#[Route('/{id}/edit', name: '{$entityLower}_edit', methods: ['GET', 'POST'])]
public function edit(Request \$request, $entityName \${$entityLower}, EntityManagerInterface \$entityManager): Response
{
\$form = \$this->createForm({$entityName}Type::class, \${$entityLower});
\$form->handleRequest(\$request);
if (\$form->isSubmitted() && \$form->isValid()) {
\$entityManager->flush();
\$this->addFlash('success', '$entityName updated successfully!');
return \$this->redirectToRoute('{$entityLower}_show', ['id' => \${$entityLower}->getId()]);
}
return \$this->render('{$entityLower}/edit.html.twig', [
'{$entityLower}' => \${$entityLower},
'form' => \$form,
]);
}
#[Route('/{id}', name: '{$entityLower}_delete', methods: ['POST'])]
public function delete(Request \$request, $entityName \${$entityLower}, EntityManagerInterface \$entityManager): Response
{
if (\$this->isCsrfTokenValid('delete'.\${$entityLower}->getId(), \$request->request->get('_token'))) {
\$entityManager->remove(\${$entityLower});
\$entityManager->flush();
\$this->addFlash('success', '$entityName deleted successfully!');
}
return \$this->redirectToRoute('{$entityLower}_index');
}
}
";
} else {
// API Controller
$controllerCode = "<?php
namespace App\Controller\Api;
use App\Entity\\$entityName;
use App\Repository\\{$entityName}Repository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Route('/api/{$entityPlural}')]
class {$entityName}ApiController extends AbstractController
{
public function __construct(
private EntityManagerInterface \$entityManager,
private SerializerInterface \$serializer,
private ValidatorInterface \$validator
) {}
#[Route('', name: 'api_{$entityLower}_index', methods: ['GET'])]
public function index({$entityName}Repository \$repository): JsonResponse
{
\${$entityPlural} = \$repository->findAll();
return \$this->json(\${$entityPlural}, Response::HTTP_OK, [], [
'groups' => ['{$entityLower}:read']
]);
}
#[Route('', name: 'api_{$entityLower}_create', methods: ['POST'])]
public function create(Request \$request): JsonResponse
{
\${$entityLower} = \$this->serializer->deserialize(
\$request->getContent(),
$entityName::class,
'json'
);
\$errors = \$this->validator->validate(\${$entityLower});
if (count(\$errors) > 0) {
\$errorMessages = [];
foreach (\$errors as \$error) {
\$errorMessages[\$error->getPropertyPath()] = \$error->getMessage();
}
return \$this->json([
'errors' => \$errorMessages
], Response::HTTP_BAD_REQUEST);
}
\$this->entityManager->persist(\${$entityLower});
\$this->entityManager->flush();
return \$this->json(\${$entityLower}, Response::HTTP_CREATED, [], [
'groups' => ['{$entityLower}:read']
]);
}
#[Route('/{id}', name: 'api_{$entityLower}_show', methods: ['GET'])]
public function show($entityName \${$entityLower}): JsonResponse
{
return \$this->json(\${$entityLower}, Response::HTTP_OK, [], [
'groups' => ['{$entityLower}:read', '{$entityLower}:detail']
]);
}
#[Route('/{id}', name: 'api_{$entityLower}_update', methods: ['PUT'])]
public function update(Request \$request, $entityName \${$entityLower}): JsonResponse
{
\$data = json_decode(\$request->getContent(), true);
\$this->serializer->deserialize(
\$request->getContent(),
$entityName::class,
'json',
['object_to_populate' => \${$entityLower}]
);
\$errors = \$this->validator->validate(\${$entityLower});
if (count(\$errors) > 0) {
\$errorMessages = [];
foreach (\$errors as \$error) {
\$errorMessages[\$error->getPropertyPath()] = \$error->getMessage();
}
return \$this->json([
'errors' => \$errorMessages
], Response::HTTP_BAD_REQUEST);
}
\$this->entityManager->flush();
return \$this->json(\${$entityLower}, Response::HTTP_OK, [], [
'groups' => ['{$entityLower}:read']
]);
}
#[Route('/{id}', name: 'api_{$entityLower}_delete', methods: ['DELETE'])]
public function delete($entityName \${$entityLower}): JsonResponse
{
\$this->entityManager->remove(\${$entityLower});
\$this->entityManager->flush();
return \$this->json(null, Response::HTTP_NO_CONTENT);
}
}
";
}
// Generate Form Type
$formCode = "<?php
namespace App\Form;
use App\Entity\\$entityName;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class {$entityName}Type extends AbstractType
{
public function buildForm(FormBuilderInterface \$builder, array \$options): void
{
\$builder
->add('name', TextType::class, [
'label' => 'Name',
'required' => true,
'attr' => [
'class' => 'form-control',
'placeholder' => 'Enter name'
]
])
->add('description', TextareaType::class, [
'label' => 'Description',
'required' => false,
'attr' => [
'class' => 'form-control',
'rows' => 4,
'placeholder' => 'Enter description'
]
])
// Add more fields based on your entity
;
}
public function configureOptions(OptionsResolver \$resolver): void
{
\$resolver->setDefaults([
'data_class' => $entityName::class,
]);
}
}
";
// Generate Templates
$baseTemplate = "{% extends 'base.html.twig' %}
{% block title %}$entityName Management{% endblock %}
{% block body %}
<div class=\"container mt-4\">
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class=\"alert alert-{{ label }} alert-dismissible fade show\" role=\"alert\">
{{ message }}
<button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"alert\" aria-label=\"Close\"></button>
</div>
{% endfor %}
{% endfor %}
{% block content %}{% endblock %}
</div>
{% endblock %}";
$indexTemplate = "{% extends '{$entityLower}/_base.html.twig' %}
{% block content %}
<div class=\"d-flex justify-content-between align-items-center mb-4\">
<h1>{$entityName} List</h1>
<a href=\"{{ path('{$entityLower}_new') }}\" class=\"btn btn-primary\">
<i class=\"bi bi-plus-circle\"></i> New $entityName
</a>
</div>
<div class=\"table-responsive\">
<table class=\"table table-striped\">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for {$entityLower} in {$entityPlural} %}
<tr>
<td>{{ {$entityLower}.id }}</td>
<td>{{ {$entityLower}.name|default('N/A') }}</td>
<td>
<a href=\"{{ path('{$entityLower}_show', {'id': {$entityLower}.id}) }}\" class=\"btn btn-sm btn-info\">
<i class=\"bi bi-eye\"></i> View
</a>
<a href=\"{{ path('{$entityLower}_edit', {'id': {$entityLower}.id}) }}\" class=\"btn btn-sm btn-warning\">
<i class=\"bi bi-pencil\"></i> Edit
</a>
<form method=\"post\" action=\"{{ path('{$entityLower}_delete', {'id': {$entityLower}.id}) }}\" style=\"display:inline-block;\" onsubmit=\"return confirm('Are you sure?');\">
<input type=\"hidden\" name=\"_token\" value=\"{{ csrf_token('delete' ~ {$entityLower}.id) }}\">
<button class=\"btn btn-sm btn-danger\">
<i class=\"bi bi-trash\"></i> Delete
</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan=\"3\" class=\"text-center\">No {$entityPlural} found</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}";
$newTemplate = "{% extends '{$entityLower}/_base.html.twig' %}
{% block content %}
<h1>Create New $entityName</h1>
<div class=\"card\">
<div class=\"card-body\">
{{ form_start(form) }}
{{ form_widget(form) }}
<div class=\"mt-3\">
<button type=\"submit\" class=\"btn btn-success\">
<i class=\"bi bi-check-circle\"></i> Create
</button>
<a href=\"{{ path('{$entityLower}_index') }}\" class=\"btn btn-secondary\">
<i class=\"bi bi-arrow-left\"></i> Back to list
</a>
</div>
{{ form_end(form) }}
</div>
</div>
{% endblock %}";
$editTemplate = "{% extends '{$entityLower}/_base.html.twig' %}
{% block content %}
<h1>Edit $entityName</h1>
<div class=\"card\">
<div class=\"card-body\">
{{ form_start(form) }}
{{ form_widget(form) }}
<div class=\"mt-3\">
<button type=\"submit\" class=\"btn btn-primary\">
<i class=\"bi bi-save\"></i> Update
</button>
<a href=\"{{ path('{$entityLower}_show', {'id': {$entityLower}.id}) }}\" class=\"btn btn-secondary\">
<i class=\"bi bi-arrow-left\"></i> Back
</a>
</div>
{{ form_end(form) }}
</div>
</div>
<div class=\"mt-3\">
<form method=\"post\" action=\"{{ path('{$entityLower}_delete', {'id': {$entityLower}.id}) }}\" onsubmit=\"return confirm('Are you sure you want to delete this item?');\">
<input type=\"hidden\" name=\"_token\" value=\"{{ csrf_token('delete' ~ {$entityLower}.id) }}\">
<button class=\"btn btn-danger\">
<i class=\"bi bi-trash\"></i> Delete this $entityName
</button>
</form>
</div>
{% endblock %}";
$showTemplate = "{% extends '{$entityLower}/_base.html.twig' %}
{% block content %}
<h1>$entityName Details</h1>
<div class=\"card\">
<div class=\"card-body\">
<table class=\"table\">
<tbody>
<tr>
<th>ID</th>
<td>{{ {$entityLower}.id }}</td>
</tr>
<tr>
<th>Name</th>
<td>{{ {$entityLower}.name|default('N/A') }}</td>
</tr>
<tr>
<th>Description</th>
<td>{{ {$entityLower}.description|default('N/A') }}</td>
</tr>
<!-- Add more fields as needed -->
</tbody>
</table>
</div>
</div>
<div class=\"mt-3\">
<a href=\"{{ path('{$entityLower}_edit', {'id': {$entityLower}.id}) }}\" class=\"btn btn-warning\">
<i class=\"bi bi-pencil\"></i> Edit
</a>
<a href=\"{{ path('{$entityLower}_index') }}\" class=\"btn btn-secondary\">
<i class=\"bi bi-arrow-left\"></i> Back to list
</a>
</div>
{% endblock %}";
// Output the generated code
echo "===========================================\n";
echo "GENERATED SYMFONY CRUD FOR: $entityName\n";
echo "===========================================\n\n";
echo "1. CONTROLLER CODE:\n";
echo "-------------------\n";
echo $controllerCode;
echo "\n\n2. FORM TYPE CODE:\n";
echo "-------------------\n";
echo $formCode;
if (!$isApi) {
echo "\n\n3. TEMPLATES:\n";
echo "-------------------\n";
echo "Base Template (templates/{$entityLower}/_base.html.twig):\n";
echo $baseTemplate;
echo "\n\n-------------------\n";
echo "Index Template (templates/{$entityLower}/index.html.twig):\n";
echo $indexTemplate;
echo "\n\n-------------------\n";
echo "New Template (templates/{$entityLower}/new.html.twig):\n";
echo $newTemplate;
echo "\n\n-------------------\n";
echo "Edit Template (templates/{$entityLower}/edit.html.twig):\n";
echo $editTemplate;
echo "\n\n-------------------\n";
echo "Show Template (templates/{$entityLower}/show.html.twig):\n";
echo $showTemplate;
}
echo "\n\n===========================================\n";
echo "INSTALLATION INSTRUCTIONS:\n";
echo "===========================================\n";
echo "1. Save the controller to: src/Controller/" . ($isApi ? "Api/{$entityName}ApiController.php" : "{$entityName}Controller.php") . "\n";
echo "2. Save the form type to: src/Form/{$entityName}Type.php\n";
if (!$isApi) {
echo "3. Create the template directory: mkdir -p templates/{$entityLower}\n";
echo "4. Save each template to its respective file in templates/{$entityLower}/\n";
}
echo "\n";
echo "REQUIRED ENTITY SERIALIZATION GROUPS (for API):\n";
echo "Add these to your entity:\n";
echo "#[Groups(['{$entityLower}:read'])]\n";
echo "#[Groups(['{$entityLower}:write'])]\n";
echo "#[Groups(['{$entityLower}:detail'])]\n";
echo "\n";
echo "Don't forget to:\n";
echo "- Ensure your entity exists: src/Entity/$entityName.php\n";
echo "- Run migrations if needed: php bin/console doctrine:migrations:migrate\n";
echo "- Clear cache: php bin/console cache:clear\n";

View File

@@ -0,0 +1,319 @@
#!/usr/bin/env php
<?php
/**
* Symfony Entity Generator Script
*
* Usage: php generate-entity.php EntityName [field:type:options ...]
* Example: php generate-entity.php Product name:string:255 price:decimal:10,2 category:relation:ManyToOne:Category
*/
if ($argc < 2) {
echo "Usage: php generate-entity.php EntityName [field:type:options ...]\n";
echo "Example: php generate-entity.php Product name:string:255 price:decimal:10,2\n";
exit(1);
}
$entityName = $argv[1];
$fields = array_slice($argv, 2);
// Generate entity class
$entityCode = "<?php
namespace App\Entity;
use App\Repository\\{$entityName}Repository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: {$entityName}Repository::class)]
class $entityName
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int \$id = null;\n\n";
$gettersSetters = "\n public function getId(): ?int
{
return \$this->id;
}\n";
foreach ($fields as $fieldDefinition) {
$parts = explode(':', $fieldDefinition);
$fieldName = $parts[0] ?? '';
$fieldType = $parts[1] ?? 'string';
$fieldOptions = $parts[2] ?? '';
if (empty($fieldName)) continue;
// Handle different field types
switch ($fieldType) {
case 'string':
$length = $fieldOptions ?: '255';
$entityCode .= " #[ORM\Column(length: $length)]
#[Assert\NotBlank]
#[Assert\Length(max: $length)]
private ?string \$$fieldName = null;\n\n";
$gettersSetters .= "\n public function get" . ucfirst($fieldName) . "(): ?string
{
return \$this->$fieldName;
}
public function set" . ucfirst($fieldName) . "(string \$$fieldName): static
{
\$this->$fieldName = \$$fieldName;
return \$this;
}\n";
break;
case 'text':
$entityCode .= " #[ORM\Column(type: 'text')]
private ?string \$$fieldName = null;\n\n";
$gettersSetters .= "\n public function get" . ucfirst($fieldName) . "(): ?string
{
return \$this->$fieldName;
}
public function set" . ucfirst($fieldName) . "(?string \$$fieldName): static
{
\$this->$fieldName = \$$fieldName;
return \$this;
}\n";
break;
case 'integer':
case 'int':
$entityCode .= " #[ORM\Column]
#[Assert\NotNull]
private ?int \$$fieldName = null;\n\n";
$gettersSetters .= "\n public function get" . ucfirst($fieldName) . "(): ?int
{
return \$this->$fieldName;
}
public function set" . ucfirst($fieldName) . "(int \$$fieldName): static
{
\$this->$fieldName = \$$fieldName;
return \$this;
}\n";
break;
case 'decimal':
case 'float':
$precision = '10';
$scale = '2';
if ($fieldOptions) {
$optionParts = explode(',', $fieldOptions);
$precision = $optionParts[0] ?? '10';
$scale = $optionParts[1] ?? '2';
}
$entityCode .= " #[ORM\Column(type: 'decimal', precision: $precision, scale: $scale)]
#[Assert\NotNull]
private ?string \$$fieldName = null;\n\n";
$gettersSetters .= "\n public function get" . ucfirst($fieldName) . "(): ?string
{
return \$this->$fieldName;
}
public function set" . ucfirst($fieldName) . "(string \$$fieldName): static
{
\$this->$fieldName = \$$fieldName;
return \$this;
}\n";
break;
case 'boolean':
case 'bool':
$entityCode .= " #[ORM\Column]
private ?bool \$$fieldName = false;\n\n";
$gettersSetters .= "\n public function is" . ucfirst($fieldName) . "(): ?bool
{
return \$this->$fieldName;
}
public function set" . ucfirst($fieldName) . "(bool \$$fieldName): static
{
\$this->$fieldName = \$$fieldName;
return \$this;
}\n";
break;
case 'datetime':
$entityCode .= " #[ORM\Column(type: 'datetime_immutable')]
private ?\\DateTimeImmutable \$$fieldName = null;\n\n";
$gettersSetters .= "\n public function get" . ucfirst($fieldName) . "(): ?\\DateTimeImmutable
{
return \$this->$fieldName;
}
public function set" . ucfirst($fieldName) . "(\\DateTimeImmutable \$$fieldName): static
{
\$this->$fieldName = \$$fieldName;
return \$this;
}\n";
break;
case 'relation':
$relationType = $parts[2] ?? 'ManyToOne';
$targetEntity = $parts[3] ?? 'RelatedEntity';
if ($relationType === 'ManyToOne') {
$entityCode .= " #[ORM\ManyToOne(inversedBy: '{$fieldName}s')]
#[ORM\JoinColumn(nullable: false)]
private ?$targetEntity \$$fieldName = null;\n\n";
$gettersSetters .= "\n public function get" . ucfirst($fieldName) . "(): ?$targetEntity
{
return \$this->$fieldName;
}
public function set" . ucfirst($fieldName) . "(?$targetEntity \$$fieldName): static
{
\$this->$fieldName = \$$fieldName;
return \$this;
}\n";
} elseif ($relationType === 'OneToMany') {
$entityCode = str_replace(
"use Doctrine\ORM\Mapping as ORM;",
"use Doctrine\Common\Collections\ArrayCollection;\nuse Doctrine\Common\Collections\Collection;\nuse Doctrine\ORM\Mapping as ORM;",
$entityCode
);
$entityCode .= " #[ORM\OneToMany(mappedBy: '" . lcfirst($entityName) . "', targetEntity: $targetEntity::class)]
private Collection \$$fieldName;\n\n";
// Add constructor if not present
if (!str_contains($gettersSetters, '__construct')) {
$gettersSetters .= "\n public function __construct()
{
\$this->$fieldName = new ArrayCollection();
}\n";
}
$gettersSetters .= "\n /**
* @return Collection<int, $targetEntity>
*/
public function get" . ucfirst($fieldName) . "(): Collection
{
return \$this->$fieldName;
}
public function add" . ucfirst(rtrim($fieldName, 's')) . "($targetEntity \$" . rtrim($fieldName, 's') . "): static
{
if (!\$this->{$fieldName}->contains(\$" . rtrim($fieldName, 's') . ")) {
\$this->{$fieldName}->add(\$" . rtrim($fieldName, 's') . ");
\$" . rtrim($fieldName, 's') . "->set" . $entityName . "(\$this);
}
return \$this;
}
public function remove" . ucfirst(rtrim($fieldName, 's')) . "($targetEntity \$" . rtrim($fieldName, 's') . "): static
{
if (\$this->{$fieldName}->removeElement(\$" . rtrim($fieldName, 's') . ")) {
if (\$" . rtrim($fieldName, 's') . "->get" . $entityName . "() === \$this) {
\$" . rtrim($fieldName, 's') . "->set" . $entityName . "(null);
}
}
return \$this;
}\n";
}
break;
}
}
$entityCode .= $gettersSetters;
$entityCode .= "}\n";
// Generate repository class
$repositoryCode = "<?php
namespace App\Repository;
use App\Entity\\$entityName;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<$entityName>
*
* @method $entityName|null find(\$id, \$lockMode = null, \$lockVersion = null)
* @method $entityName|null findOneBy(array \$criteria, array \$orderBy = null)
* @method {$entityName}[] findAll()
* @method {$entityName}[] findBy(array \$criteria, array \$orderBy = null, \$limit = null, \$offset = null)
*/
class {$entityName}Repository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry \$registry)
{
parent::__construct(\$registry, $entityName::class);
}
public function save($entityName \$entity, bool \$flush = false): void
{
\$this->getEntityManager()->persist(\$entity);
if (\$flush) {
\$this->getEntityManager()->flush();
}
}
public function remove($entityName \$entity, bool \$flush = false): void
{
\$this->getEntityManager()->remove(\$entity);
if (\$flush) {
\$this->getEntityManager()->flush();
}
}
// Example custom query methods:
/**
* Find published {$entityName}s ordered by creation date
*
* @return {$entityName}[]
*/
public function findPublishedOrderedByDate(): array
{
return \$this->createQueryBuilder('e')
->andWhere('e.published = :published')
->setParameter('published', true)
->orderBy('e.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Find {$entityName}s by search term
*
* @return {$entityName}[]
*/
public function findBySearchTerm(string \$term): array
{
return \$this->createQueryBuilder('e')
->andWhere('e.name LIKE :term OR e.description LIKE :term')
->setParameter('term', '%' . \$term . '%')
->orderBy('e.name', 'ASC')
->getQuery()
->getResult();
}
}
";
echo "Generated Entity Code:\n";
echo "======================\n";
echo $entityCode;
echo "\n\nGenerated Repository Code:\n";
echo "==========================\n";
echo $repositoryCode;
echo "\n\nTo use this code:\n";
echo "1. Save the entity code to: src/Entity/$entityName.php\n";
echo "2. Save the repository code to: src/Repository/{$entityName}Repository.php\n";
echo "3. Run: php bin/console make:migration\n";
echo "4. Run: php bin/console doctrine:migrations:migrate\n";