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.
Co z tego wyniesiesz
Dział zatytułowany „Co z tego wyniesiesz”- 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::rememberwszę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ę
Szybka konfiguracja
Dział zatytułowany „Szybka konfiguracja”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.
-
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"}} -
Daj agentowi plik reguł. Cursor czyta reguły projektu z
.cursor/rules/*.mdc, Claude Code czytaCLAUDE.md, a Codex czytaAGENTS.md. Treść jest ta sama — zmienia się tylko ścieżka. (Dawny jednoplikowy.cursorrulesnadal 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
Laravel: endpoint REST z cienkim kontrolerem
Dział zatytułowany „Laravel: endpoint REST z cienkim kontrolerem”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:
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:
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); }}Eliminacja N+1, zanim trafi na produkcję
Dział zatytułowany „Eliminacja N+1, zanim trafi na produkcję”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ł.
Modernizacja legacy
Dział zatytułowany „Modernizacja legacy”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 intoApp\Repository\UserRepository::findById(int $id): ?User. Use PDO with a prepared statement and bound parameters — no string interpolation into SQL. Return a typedUseror null. Wrap PDO failures in aRepositoryException. 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.
Uruchom z katalogu głównego repozytorium, żeby Claude Code mógł wygrepować każde wywołanie w całym projekcie, a nie tylko w otwartym pliku.
claude "Modernize the legacy mysql_* user query in legacy/users.php:1. Create App\\Repository\\UserRepository with findById(int \$id): ?User using PDO prepared statements (bound params, no interpolation)2. Wrap PDO errors in a RepositoryException3. Find and update every caller of the old get_user() function repo-wide4. Add a PHPUnit test mocking the PDO connection and PDOStatement5. Run vendor/bin/phpstan and vendor/bin/pint and fix what they flag"Codex błyszczy w refaktoryzacjach wielu plików dzięki swojemu przepływowi z worktree — odpal go na izolowanej gałęzi w sandboksie, żeby zmiany w legacy były odseparowane, dopóki nie przejrzysz diffa.
codex "Modernize the mysql_* user access in legacy/users.php into a typedApp\\Repository\\UserRepository (PDO, prepared statements, bound params, nostring interpolation). Update all call sites, wrap PDO errors inRepositoryException, and add a PHPUnit test that mocks the PDO connection."Do przebiegu bez nadzoru na zaufanym repozytorium dodaj --full-auto; przy nieznanym kodzie legacy zostaw domyślne --ask-for-approval on-request, żeby zatwierdzać każdy zapis pliku.
Docelowy kształt — zwróć uwagę na parametr związany (bound parameter) i typowaną wartość zwracaną:
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); } }}Cache’owanie z jawnym unieważnianiem
Dział zatytułowany „Cache’owanie z jawnym unieważnianiem”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.
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); }}Wariant Symfony
Dział zatytułowany „Wariant Symfony”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.
Testowanie z PHPUnit
Dział zatytułowany „Testowanie z PHPUnit”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.
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], ]; }}MCP i Skille, które zmieniają przepływ pracy
Dział zatytułowany „MCP i Skille, które zmieniają przepływ pracy”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 stylumongodb-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.