CtrlK
BlogDocsLog inGet started
Tessl Logo

symfony-project-starter

Scaffold and develop a Symfony 7.x application with PHP 8.3+, Doctrine ORM, API Platform, Messenger async processing, and Twig templating.

78

Quality

72%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Optimize this skill with Tessl

npx tessl skill review --optimize ./backend-php/symfony-project-starter/SKILL.md
SKILL.md
Quality
Evals
Security

Symfony Project Starter

Scaffold and develop a Symfony 7.x application with PHP 8.3+, Doctrine ORM, API Platform, Messenger async processing, and Twig templating.

Prerequisites

  • PHP 8.3+
  • Composer 2.x
  • Symfony CLI (symfony)
  • PostgreSQL or MySQL
  • Node.js 20+ (for Webpack Encore asset pipeline, if needed)

Scaffold Command

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

Project Structure

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

Key Conventions

  • One entity per file in src/Entity/, annotated with PHP 8 attributes (not annotations).
  • Repositories extend ServiceEntityRepository and live in src/Repository/.
  • Controllers are thin: inject services, validate input, return responses. No business logic in controllers.
  • Services are auto-wired via services.yaml defaults. Use constructor injection exclusively.
  • Messenger messages are plain DTOs in src/Message/; handlers in src/MessageHandler/.
  • Environment-specific config goes in .env and .env.local (never commit .env.local).
  • API Platform resources are declared via PHP attributes directly on entities.
  • Migrations are generated, never hand-written: php bin/console make:migration.

Essential Patterns

Entity with Doctrine ORM + API Platform

<?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;
    }
}

Repository with Custom Query

<?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();
    }
}

Controller

<?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,
        ]);
    }
}

Service with Dependency Injection

<?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,
        ]);
    }
}

Messenger: Async Message + Handler

<?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);
    }
}

Messenger Transport Config

# 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

Twig Template

{# 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 }} &mdash; ${{ product.price }}
            </a>
        </li>
    {% else %}
        <li>No products found.</li>
    {% endfor %}
</ul>
{% endblock %}

PHPUnit Functional Test

<?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');
    }
}

First Steps After Scaffold

  1. Copy .env to .env.local and fill in DATABASE_URL and MESSENGER_TRANSPORT_DSN with your local values
  2. Run composer install to install all dependencies
  3. Create the database and run migrations: php bin/console doctrine:database:create && php bin/console doctrine:migrations:migrate
  4. Start the dev server: symfony server:start
  5. Verify the API docs endpoint: open http://localhost:8000/api in a browser

Common Commands

# 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

Integration Notes

  • Database: Configure DATABASE_URL in .env. Supports PostgreSQL (pdo_pgsql), MySQL (pdo_mysql), SQLite (pdo_sqlite).
  • API Platform: Exposes REST + JSON-LD by default at /api. GraphQL available via composer require api-platform/graphql.
  • Messenger: Set MESSENGER_TRANSPORT_DSN to doctrine://default for DB transport, or amqp://guest:guest@localhost:5672 for RabbitMQ.
  • Docker: Use docker compose up -d with the generated compose.yaml for database + mailcatcher.
  • Frontend: Pair with Webpack Encore (composer require symfony/webpack-encore-bundle) or AssetMapper for lightweight CSS/JS.
  • Auth: Use symfony/security-bundle with make:user, make:auth for login forms or API token auth.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

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.