Scaffold and develop a Symfony 7.x application with PHP 8.3+, Doctrine ORM, API Platform, Messenger async processing, and Twig templating.
78
72%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./backend-php/symfony-project-starter/SKILL.mdScaffold 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-bundlemy-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.locksrc/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=8DATABASE_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.181fcbc
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.