backend-php/symfony-project-starter/SKILL.md
Scaffold and develop a Symfony 7.x application with PHP 8.3+, Doctrine ORM, API Platform, Messenger async processing, and Twig templating.
npx skillsauth add achreftlili/deep-dev-skills symfony-project-starterInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Scaffold and develop a Symfony 7.x application with PHP 8.3+, Doctrine ORM, API Platform, Messenger async processing, and Twig templating.
symfony)symfony new my-project --version="7.2.*" --webapp
cd my-project
composer require api-platform/api-pack
composer require symfony/messenger
composer require --dev phpunit/phpunit symfony/test-pack symfony/maker-bundle
my-project/
├── config/
│ ├── packages/ # Bundle configuration (doctrine.yaml, messenger.yaml, etc.)
│ ├── routes/ # Route imports
│ └── services.yaml # Dependency injection definitions
├── migrations/ # Doctrine migrations
├── public/
│ └── index.php # Front controller
├── src/
│ ├── Controller/ # HTTP controllers
│ ├── Entity/ # Doctrine ORM entities
│ ├── Repository/ # Doctrine repositories
│ ├── Message/ # Messenger message classes
│ ├── MessageHandler/ # Messenger handlers
│ ├── Service/ # Business logic services
│ ├── EventSubscriber/ # Event subscribers
│ └── Kernel.php
├── templates/ # Twig templates
│ └── base.html.twig
├── tests/
│ ├── Unit/
│ └── Functional/
├── .env # Environment variables (DATABASE_URL, MESSENGER_TRANSPORT_DSN)
├── .env.example # Symfony creates this natively as .env — commit .env with defaults, use .env.local for overrides
├── composer.json
└── symfony.lock
src/Entity/, annotated with PHP 8 attributes (not annotations).ServiceEntityRepository and live in src/Repository/.services.yaml defaults. Use constructor injection exclusively.src/Message/; handlers in src/MessageHandler/..env and .env.local (never commit .env.local).php bin/console make:migration.<?php
// src/Entity/Product.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\Delete;
use App\Repository\ProductRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[ORM\Table(name: 'products')]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Put(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
paginationItemsPerPage: 20,
)]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
private string $name;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2)]
#[Assert\Positive]
private string $price;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getPrice(): string
{
return $this->price;
}
public function setPrice(string $price): static
{
$this->price = $price;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}
<?php
// src/Repository/ProductRepository.php
namespace App\Repository;
use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ProductRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Product::class);
}
/**
* @return Product[]
*/
public function findByMinPrice(string $minPrice): array
{
return $this->createQueryBuilder('p')
->andWhere('p.price >= :minPrice')
->setParameter('minPrice', $minPrice)
->orderBy('p.price', 'ASC')
->getQuery()
->getResult();
}
}
<?php
// src/Controller/ProductController.php
namespace App\Controller;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/products')]
class ProductController extends AbstractController
{
#[Route('', name: 'product_index', methods: ['GET'])]
public function index(ProductRepository $productRepository): Response
{
return $this->render('product/index.html.twig', [
'products' => $productRepository->findAll(),
]);
}
#[Route('/{id}', name: 'product_show', methods: ['GET'])]
public function show(int $id, ProductRepository $productRepository): Response
{
$product = $productRepository->find($id);
if (!$product) {
throw $this->createNotFoundException('Product not found.');
}
return $this->render('product/show.html.twig', [
'product' => $product,
]);
}
}
<?php
// src/Service/PricingService.php
namespace App\Service;
use App\Repository\ProductRepository;
use Psr\Log\LoggerInterface;
class PricingService
{
public function __construct(
private readonly ProductRepository $productRepository,
private readonly LoggerInterface $logger,
) {}
public function applyDiscount(int $productId, float $percent): void
{
$product = $this->productRepository->find($productId);
if (!$product) {
throw new \InvalidArgumentException("Product {$productId} not found.");
}
$original = $product->getPrice();
$discounted = bcmul($original, (string)(1 - $percent / 100), 2);
$product->setPrice($discounted);
$this->logger->info('Applied {percent}% discount to product {id}', [
'percent' => $percent,
'id' => $productId,
]);
}
}
<?php
// src/Message/SendOrderConfirmation.php
namespace App\Message;
final readonly class SendOrderConfirmation
{
public function __construct(
public int $orderId,
public string $customerEmail,
) {}
}
<?php
// src/MessageHandler/SendOrderConfirmationHandler.php
namespace App\MessageHandler;
use App\Message\SendOrderConfirmation;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
#[AsMessageHandler]
final class SendOrderConfirmationHandler
{
public function __construct(
private readonly MailerInterface $mailer,
) {}
public function __invoke(SendOrderConfirmation $message): void
{
$email = (new Email())
->to($message->customerEmail)
->subject("Order #{$message->orderId} Confirmed")
->text("Your order #{$message->orderId} has been confirmed.");
$this->mailer->send($email);
}
}
# config/packages/messenger.yaml
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
routing:
App\Message\SendOrderConfirmation: async
{# templates/product/index.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Products{% endblock %}
{% block body %}
<h1>Products</h1>
<ul>
{% for product in products %}
<li>
<a href="{{ path('product_show', {id: product.id}) }}">
{{ product.name }} — ${{ product.price }}
</a>
</li>
{% else %}
<li>No products found.</li>
{% endfor %}
</ul>
{% endblock %}
<?php
// tests/Functional/ProductControllerTest.php
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ProductControllerTest extends WebTestCase
{
public function testProductIndexReturns200(): void
{
$client = static::createClient();
$client->request('GET', '/products');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('h1');
}
}
.env to .env.local and fill in DATABASE_URL and MESSENGER_TRANSPORT_DSN with your local valuescomposer install to install all dependenciesphp bin/console doctrine:database:create && php bin/console doctrine:migrations:migratesymfony server:starthttp://localhost:8000/api in a browser# Start dev server
symfony server:start
# Database
php bin/console doctrine:database:create
php bin/console make:entity
php bin/console make:migration
php bin/console doctrine:migrations:migrate
# Code generation
php bin/console make:controller ProductController
php bin/console make:command AppSyncProductsCommand
# Messenger
php bin/console messenger:consume async -vv
# Cache
php bin/console cache:clear
# Tests
php bin/phpunit
php bin/phpunit --filter=ProductControllerTest
# Linting
composer require --dev friendsofphp/php-cs-fixer
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/php-cs-fixer fix
# Static analysis
composer require --dev phpstan/phpstan phpstan/phpstan-symfony
vendor/bin/phpstan analyse src --level=8
DATABASE_URL in .env. Supports PostgreSQL (pdo_pgsql), MySQL (pdo_mysql), SQLite (pdo_sqlite)./api. GraphQL available via composer require api-platform/graphql.MESSENGER_TRANSPORT_DSN to doctrine://default for DB transport, or amqp://guest:guest@localhost:5672 for RabbitMQ.docker compose up -d with the generated compose.yaml for database + mailcatcher.composer require symfony/webpack-encore-bundle) or AssetMapper for lightweight CSS/JS.symfony/security-bundle with make:user, make:auth for login forms or API token auth.testing
Set up Vitest 2.x with TypeScript for unit and component testing using test/describe/it, vi.fn/vi.mock/vi.spyOn, component testing with Testing Library, coverage (v8/istanbul), workspace config, and snapshot testing.
testing
Set up pytest 8.x with Python for unit and integration testing using fixtures (scope, autouse, parametrize), async tests (pytest-asyncio), mocking (unittest.mock, pytest-mock), coverage (pytest-cov), conftest.py patterns, and markers.
testing
Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.
testing
Set up Jest 30+ with TypeScript for unit tests, integration tests, mocking (jest.fn, jest.mock, jest.spyOn), coverage configuration, custom matchers, snapshot testing, and setup/teardown patterns.