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