Przejdź do głównej zawartości

Wzorce rozwoju PHP

Odziedziczyłeś monolit w Laravelu. Kontrolery mają po 400 linii, połowa dostępu do danych to surowe wywołania mysql_*, które kontraktor zostawił w 2017 roku, testów jest zero, a PM chce nowy endpoint REST API na piątek. Skierowanie agenta AI na taki kod bez barier ochronnych daje ci zapytania N+1 w Eloquent, grube kontrolery, które przechodzą code review tylko dzięki szczęściu, oraz encje, do których nigdy nie powstają migracje.

Ten przewodnik pokazuje, jak prowadzić Cursor, Claude Code i Codex przez realną pracę z Laravelem i Symfony, tak aby wynik miał kształt produkcyjny, a nie demonstracyjny: cienkie kontrolery, warstwę serwisów, uruchamialne testy i ścieżkę modernizacji dla fragmentów legacy.

  • Powtarzalny prompt do generowania endpointu REST w Laravelu z cienkim kontrolerem i warstwą serwisów (zero logiki biznesowej w kontrolerze)
  • Prompt do modernizacji legacy PHP opartego na mysql_* / funkcjach globalnych w typowane repozytoria zgodne z PSR-4 i oparte na PDO
  • Prompt do warstwy cache’owania, który dodaje Redis z jawnym unieważnianiem, zamiast ślepego Cache::remember wszędzie
  • Prompt do PHPUnit, który tworzy testy faktycznie uruchamialne na czystej instalacji (bez brakującej zależności Prophecy)
  • Tryby awarii, które gryzą PHP generowane przez AI, i jak je wyłapać, zanim trafią na produkcję

Konfiguracja jest identyczna we wszystkich trzech narzędziach — różni się tylko nazwa pliku z regułami. Przypnij aktualny stos i daj agentowi plik reguł, żeby przestał sięgać po wzorce z czasów Laravela 11.

  1. Przypnij aktualny composer.json. Laravel 13 (wydany w marcu 2026) wymaga PHP 8.3+, a PHPUnit 13 to bieżąca wersja główna. Nie pozwól agentowi tworzyć szkieletu na wersjach po końcu wsparcia (EOL).

    composer.json
    {
    "require": {
    "php": "^8.3",
    "laravel/framework": "^13.0",
    "laravel/sanctum": "^4.0"
    },
    "require-dev": {
    "phpunit/phpunit": "^13.0",
    "mockery/mockery": "^1.6",
    "phpstan/phpstan": "^2.0",
    "laravel/pint": "^1.18"
    }
    }
  2. Daj agentowi plik reguł. Cursor czyta reguły projektu z .cursor/rules/*.mdc, Claude Code czyta CLAUDE.md, a Codex czyta AGENTS.md. Treść jest ta sama — zmienia się tylko ścieżka. (Dawny jednoplikowy .cursorrules nadal działa w Cursorze, ale został zastąpiony przez .cursor/rules/.)

    - Celuj w PHP 8.3+; używaj właściwości readonly, enumów i promocji w konstruktorze
    - Trzymaj się PSR-12; uruchom `vendor/bin/pint`, zanim uznasz zadanie za skończone
    - Kontrolery pozostają cienkie: walidacja w Form Requests, logika w serwisach
    - Deklaruj typ każdego parametru i zwracanej wartości; żadnych nietypowanych tablic dla danych domenowych
    - Eager-load relacji w zapytaniach; nigdy nie wywołuj N+1 w pętli
    - Każdy wygenerowany model potrzebuje pasującej migracji w tej samej zmianie
    - Napisz test PHPUnit dla każdej publicznej metody serwisu

Najczęstszym błędem jest poproszenie o „API produktów” i otrzymanie 200-linijkowego kontrolera z budowaniem zapytań, walidacją i formatowaniem odpowiedzi w jednym miejscu. Zakotwicz prompt na architekturze, której chcesz.

Oto kształt, który prompt powinien wyprodukować. Zwróć uwagę, że kontroler nie robi nic poza delegowaniem:

app/Http/Controllers/Api/V1/ProductController.php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreProductRequest;
use App\Http\Resources\ProductResource;
use App\Services\ProductService;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ProductController extends Controller
{
public function __construct(
private readonly ProductService $products
) {}
public function index(): AnonymousResourceCollection
{
return ProductResource::collection(
$this->products->paginate(
perPage: request()->integer('per_page', 15),
search: request()->string('search')->toString(),
)
);
}
public function store(StoreProductRequest $request): ProductResource
{
$product = $this->products->create($request->validated());
return ProductResource::make($product);
}
}

Serwis to miejsce, w którym agent powinien umieścić granicę transakcji i wysyłanie zdarzeń — utrzymaj go w ryzach, sprawdzając, czy create() opakowuje zapisy w DB::transaction i czy szwem dostępu do danych jest repozytorium, a nie Eloquent:

app/Services/ProductService.php
namespace App\Services;
use App\Events\ProductCreated;
use App\Models\Product;
use App\Repositories\ProductRepository;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class ProductService
{
public function __construct(
private readonly ProductRepository $repository
) {}
public function create(array $data): Product
{
return DB::transaction(function () use ($data) {
$product = $this->repository->create($data);
event(new ProductCreated($product));
return $product;
});
}
public function paginate(int $perPage, string $search): LengthAwarePaginator
{
return $this->repository->paginate($perPage, $search);
}
}

Pojedynczą najczęstszą regresją produkcyjną z Eloquent generowanego przez AI jest zapytanie N+1 — @foreach w widoku Blade albo API resource, który dla każdego wiersza leniwie ładuje relację. Każ agentowi udowodnić, że tego nie zrobił.

To tutaj wsparcie AI się zwraca: mechaniczna konwersja wywołań mysql_* i funkcji globalnych w typowane, wstrzykiwalne repozytoria. Ryzyko polega na tym, że agent „modernizuje” składnię, po cichu zachowując dziurę umożliwiającą SQL injection (interpolowane w łańcuchu $id wprost do zapytania).

Otwórz plik legacy, zaznacz funkcję i użyj trybu Agent, żeby mógł odczytać otaczające miejsca wywołań oraz utworzyć nową klasę plus test w jednym przebiegu.

Prompt: Modernize the selected get_user() function into App\Repository\UserRepository::findById(int $id): ?User. Use PDO with a prepared statement and bound parameters — no string interpolation into SQL. Return a typed User or null. Wrap PDO failures in a RepositoryException. Then update every call site in this file and add a PHPUnit test that mocks the PDO connection.

Użyj funkcji checkpointów w Cursorze przed zaakceptowaniem — refaktoryzacje legacy to dokładnie ten przypadek, w którym chcesz mieć cofnięcie jednym kliknięciem, jeśli któreś miejsce wywołania się zepsuje.

Docelowy kształt — zwróć uwagę na parametr związany (bound parameter) i typowaną wartość zwracaną:

app/Repository/UserRepository.php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use PDO;
use PDOException;
final class UserRepository
{
public function __construct(
private readonly PDO $connection
) {}
public function findById(int $id): ?User
{
try {
$statement = $this->connection->prepare(
'SELECT id, email, name, created_at FROM users WHERE id = :id'
);
$statement->execute(['id' => $id]);
$data = $statement->fetch(PDO::FETCH_ASSOC);
return $data === false ? null : User::fromArray($data);
} catch (PDOException $e) {
throw new RepositoryException("Failed to fetch user {$id}", 0, $e);
}
}
}

Naiwny prompt do cache’owania w AI produkuje wywołania Cache::remember() rozsiane po całym kodzie bez strategii unieważniania — a trzy dni później nieaktualne dane na produkcji. Poproś o ścieżkę unieważniania od razu.

app/Service/CachedProductService.php
namespace App\Service;
use App\Entity\Product;
use App\Repository\ProductRepository;
use Psr\Cache\CacheItemPoolInterface;
final class CachedProductService
{
private const CACHE_TTL = 3600;
private const PREFIX = 'product:';
public function __construct(
private readonly ProductRepository $repository,
private readonly CacheItemPoolInterface $cache,
) {}
public function findById(int $id): ?Product
{
$item = $this->cache->getItem(self::PREFIX . $id);
if ($item->isHit()) {
return $item->get();
}
$product = $this->repository->find($id);
if ($product !== null) {
$item->set($product)->expiresAfter(self::CACHE_TTL);
$this->cache->save($item);
}
return $product;
}
public function invalidate(int $id): void
{
$this->cache->deleteItem(self::PREFIX . $id);
}
}

Jeśli pracujesz na Symfony, a nie na Laravelu, ta sama architektura mapuje się na wstrzykiwane przez konstruktor serwisy i repozytoria Doctrine. Przypnij stos równie świadomie: Symfony 8.1 to bieżące wydanie (wymaga PHP 8.4+), a Symfony 7.4 LTS (wspierane do 2029 roku) to zachowawczy wybór dla aplikacji enterprise, które nie mogą gonić półrocznych wydań minor. Encje Doctrine używają atrybutów PHP 8 do mapowania, a repozytoria rozszerzają ServiceEntityRepository. Prowadź to tym samym promptem „cienki kontroler, logika w serwisie, repozytorium jako szew danych” — zmieniają się tylko prymitywy frameworka.

To sekcja, którą PHP generowane przez AI najczęściej subtelnie psuje. Agenci uwielbiają Prophecy\PhpUnit\ProphecyTrait — ale wbudowana integracja Prophecy z PHPUnit została usunięta w PHPUnit 10 i obecnie znajduje się w osobnym pakiecie phpspec/prophecy-phpunit. Jeśli agent wygeneruje test z ProphecyTrait bez dodania tej zależności, test nawet się nie załaduje na aktualnym PHPUnit. Na lekcję „nowoczesnego PHP” wybierz natywne createMock() z PHPUnit — działa na czystej instalacji bez żadnych dodatkowych pakietów.

tests/Unit/Service/OrderServiceTest.php
declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Entity\Order;
use App\Enum\OrderStatus;
use App\Exception\InvalidOrderStateException;
use App\Repository\OrderRepository;
use App\Service\NotificationService;
use App\Service\OrderService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
class OrderServiceTest extends TestCase
{
#[Test]
public function it_completes_a_processing_order(): void
{
$order = (new Order())->setId(123)->setStatus(OrderStatus::Processing);
$repository = $this->createMock(OrderRepository::class);
$repository->method('find')->with(123)->willReturn($order);
$repository->expects($this->once())->method('save')->with($order);
$notifications = $this->createMock(NotificationService::class);
$notifications->expects($this->once())->method('sendOrderCompleted')->with($order);
$service = new OrderService($repository, $notifications);
$this->assertTrue($service->completeOrder(123));
$this->assertSame(OrderStatus::Completed, $order->getStatus());
}
#[Test]
#[DataProvider('invalidStatuses')]
public function it_rejects_completion_from_invalid_status(OrderStatus $status): void
{
$order = (new Order())->setId(123)->setStatus($status);
$repository = $this->createMock(OrderRepository::class);
$repository->method('find')->with(123)->willReturn($order);
$service = new OrderService($repository, $this->createMock(NotificationService::class));
$this->expectException(InvalidOrderStateException::class);
$service->completeOrder(123);
}
public static function invalidStatuses(): array
{
return [
'already completed' => [OrderStatus::Completed],
'cancelled' => [OrderStatus::Cancelled],
'refunded' => [OrderStatus::Refunded],
];
}
}

Dwie integracje wyraźnie przyspieszają pracę z PHP we wszystkich trzech narzędziach:

  • Serwer MCP Postgres / MySQL — pozwala agentowi odczytać twój prawdziwy schemat zamiast zgadywać nazwy kolumn. Generowane migracje i casty Eloquent przestają dryfować względem rzeczywistej bazy danych. Serwer Postgres jest na npm jako @modelcontextprotocol/server-postgres; dla MySQL istnieją społecznościowe serwery w stylu mongodb-mcp-server, ale zweryfikuj go, zanim mu zaufasz.
  • Serwer MCP GitHub (@modelcontextprotocol/server-github) — idealnie współgra z przepływem modernizacji Codeksa opartym na worktree: agent otwiera PR, a ty przeglądasz diff legacy w interfejsie GitHuba.

Lekką alternatywą dla pełnego serwera MCP jest Agent Skill do skoncentrowanego, powtarzalnego zadania (np. skill „konwersja adnotacji na atrybuty PHPUnit”). Skille instaluje się uniwersalnym CLI z vercel-labs/skills: npx skills add <owner>/<repo>. Sięgaj po skill, gdy chcesz jednozadaniowego rozszerzenia; sięgaj po serwer MCP, gdy potrzebujesz trwałego połączenia, takiego jak dostęp do bazy danych na żywo.