Przejdź do głównej zawartości

Wzorce programowania w PHP dla agentów AI to konfiguracja projektu, prompty i bramki, dzięki którym Claude Code, Codex i Cursor dostarczają kod w Laravelu 13 i Symfony przyjmowany na podstawie dowodów: kontekst frameworka z Laravel Boost, testy pisane przed kodem, Pint, PHPStan i PHPUnit jako definicja ukończenia, ścisły tryb Eloquenta przeciw N+1 oraz testy charakteryzujące przed przepisywaniem legacy.

Ta strona jest dla programistów PHP, za których większość funkcji pisze już agent, oraz dla tech leadów, którzy chcą, żeby w CI cały zespół podlegał tym samym bramkom. Sytuacja, którą rozwiązuje: pull request od agenta jest zielony, tyle że composer.json przypina PHPUnit 13 na PHP 8.3, połowa testów używa adnotacji @test, których PHPUnit 13 w ogóle nie uruchamia, lista produktów leniwie dociąga kategorię dla każdego wiersza, a „zmodernizowane” repozytorium legacy dalej wkleja $id prosto w SQL. Poniższe receptury sprawiają, że agent zderza się z tymi problemami w każdej turze, więc przeglądasz wyniki testów i bramek, a nie każdą linię kodu.

  • Tabelę wersji z ograniczeniem PHP, którego naprawdę wymaga każdy aktualny pakiet.
  • Laravel Boost podłączony do Claude Code, Codeksa i Cursora, razem z jego dwiema pułapkami.
  • AGENTS.md dla aplikacji Laravel i prompt, który szkicuje go z twojego repozytorium.
  • Pętlę „testy najpierw” dla endpointu Laravel z cienkim kontrolerem.
  • Hook Stop w Claude Code i job GitHub Actions, które odrzucają „gotowe”, dopóki Pint, PHPStan i PHPUnit nie przejdą.
  • Ścisły tryb Eloquenta i test liczby zapytań, dzięki którym N+1 kończy się błędem w testach, a nie na produkcji.
  • Recepturę modernizacji legacy mysql_*, która najpierw przypina zachowanie, a na końcu sprawdza, czy SQL injection nie przetrwało.
  • Test PHPUnit 13 bez ostrzeżeń o deprecjacji oraz testy mutacyjne w Infection, które pokazują, że testy potrafią się wywrócić.

Jakie wersje PHP, Laravela i Symfony zakładają te receptury?

Dział zatytułowany „Jakie wersje PHP, Laravela i Symfony zakładają te receptury?”

Agenci mieszają API od Laravela 8 do 13 i od PHPUnit 9 do 13, bo tak wyglądają ich dane treningowe. Podaj agentowi wersje i pozwól, żeby przypiął je composer.lock. Tak wyglądały najnowsze stabilne wydania w Packagist pod koniec września 2026:

PakietNajnowsze wydanieWymagane PHP
laravel/framework13.33.0 (2026-09-22); 13.0.0 wyszedł 2026-03-17^8.3
phpunit/phpunit13.3.5 (2026-09-25)>=8.4.1
symfony/symfony8.1.7 (2026-09-15); 7.4.19 na gałęzi długoterminowego wsparcia (LTS)>=8.4.1 (8.1), >=8.2 (7.4)
phpstan/phpstan · larastan/larastan2.2.16 · 3.12.2^7.4|^8.0 · ^8.2
laravel/pint1.32.1^8.3.0
laravel/boost2.10.0 (2026-09-23)^8.2
infection/infection0.35.4 (2026-09-02)^8.3

Wiersz, który gryzie: Laravel 13 działa na PHP 8.3, ale PHPUnit 13 wymaga PHP 8.4.1 lub nowszego. Dlatego świeży szkielet Laravela 13 przypina "phpunit/phpunit": "^12.5.12". Jeśli agent „podbije” PHPUnit do 13, a composer.json dalej mówi "php": "^8.3", Composer nie rozwiąże zależności na maszynie z PHP 8.3. Wybierz jedno: zostań na PHPUnit 12.5 dla PHP 8.3 albo wymagaj "php": "^8.4" i przejdź na PHPUnit 13.

Jak dać agentowi kontekst Laravela przez Laravel Boost?

Dział zatytułowany „Jak dać agentowi kontekst Laravela przez Laravel Boost?”

Laravel Boost to oficjalny pakiet Laravela dla agentów AI. Zapisuje w repozytorium wytyczne frameworka i skille oraz uruchamia lokalny serwer MCP. Nowy szkielet Laravela 13 ma już AGENTS.md i CLAUDE.md, które każą agentowi zainstalować Boost, zanim zrobi cokolwiek innego, więc agent otwierający świeżą aplikację i tak o niego poprosi. Zainstaluj go w katalogu głównym aplikacji:

Okno terminala
composer require laravel/boost --dev
php artisan boost:install

boost:install pyta, jakich agentów używasz i które części zainstalować (--guidelines, --skills i --mcp wybierają je bez pytań). Domyślne ścieżki według kodu Boost 2.10.0, każdą można zmienić w config/boost.php:

AgentSerwer MCP rejestrowany wWytyczne zapisywane doSkille zapisywane do
Claude Code.mcp.jsonAGENTS.md.claude/skills
Codex.codex/config.tomlAGENTS.md.agents/skills
Cursor.cursor/mcp.jsonAGENTS.md.cursor/skills

Serwer MCP (php artisan boost:mcp) daje agentowi narzędzia, które czytają schemat bazy, wykonują SQL tylko do odczytu, przeszukują dokumentację dokładnie dla wersji pakietów z twojego pliku composer.lock, czytają ostatni wyjątek i log aplikacji oraz wykonują PHP przez Tinkera. Najwięcej zmienia narzędzie schematu: generowane migracje, casty i fabryki przestają zgadywać nazwy kolumn. Po aktualizacji Laravela lub Boosta uruchom php artisan boost:update, żeby odświeżyć wytyczne i skille.

Dwie rzeczy, których dokumentacja Boosta nie stawia na pierwszym planie:

  • Boost startuje tylko w środowisku lokalnym albo przy APP_DEBUG=true. Na stagingu lub hoście zbliżonym do produkcji, gdzie APP_ENV nie jest local, a APP_DEBUG=false, serwer MCP nie rejestruje narzędzi, więc agent działający tam nie ma dostępu do schematu. To właściwe zachowanie; nie włączaj debugu, żeby je „naprawić”.
  • Narzędzie SQL jest tylko do odczytu, Tinker nie. Narzędzie zapytań Boosta odrzuca wszystko poza SELECT, SHOW, EXPLAIN, DESCRIBE i WITH … SELECT i wykonuje zapytanie w transakcji, którą wycofuje. Narzędzie Tinker wykonuje dowolny PHP w twojej aplikacji, łącznie z Product::query()->delete(). Skieruj .env na jednorazową lokalną bazę, nigdy na kopię produkcji z danymi klientów.

W Symfony odpowiednika Boosta nie ma. Napisz poniższy AGENTS.md ręcznie i podłącz serwer bazodanowy tylko do odczytu z serwerów MCP dla baz danych, żeby agent czytał prawdziwy schemat.

Wytyczne Boosta obejmują konwencje Laravela. Twój plik dodaje to, co wie tylko twój kod: polecenia, które agent musi uruchomić, w kolejności, w jakiej ma je uruchomić, oraz kilka reguł, które ogólny model w tym repozytorium myli. Pomiń wszystko, co już egzekwuje Pint lub PHPStan. Wpisz to do pliku, do którego pisze Boost, nad lub pod wygenerowanym przez niego blokiem.

AGENTS.md
# Shop (Laravel 13, PHP 8.4, MySQL 8, PHPUnit 13)
## Commands (run from the repo root)
- One test: `php artisan test --filter=ProductIndexTest`
- Style check: `vendor/bin/pint --test` (fix with `vendor/bin/pint --dirty`)
- Static analysis: `vendor/bin/phpstan analyse` (Larastan, level 8)
- Full gate: `vendor/bin/pint --test && vendor/bin/phpstan analyse && php artisan test`
## Definition of done
The full gate passes. Report the command you ran and its last lines.
## Rules
- Create classes with `php artisan make:*`, never by hand; every model gets a migration and a factory.
- Controllers only validate (Form Request), call one service method and return an API Resource.
- Tests are the specification. If a test fails, fix app/, not tests/. Ask before changing an assertion.
- Test attributes (`#[Test]`, `#[DataProvider]`), never doc-comment annotations.
- Every query that feeds a collection eager-loads with `with()`; strict mode throws on lazy loading.
- Raw SQL only through bound parameters; never interpolate a variable into a query string.
- Money is integer cents (`*_cents`), never float.

Każde narzędzie wczytuje ten plik inaczej:

Utwórz CLAUDE.md, którego pierwsza linia importuje wspólny plik, a pod nią dopisz linie tylko dla Claude’a:

CLAUDE.md
@AGENTS.md

CLAUDE.md ze szkieletu zawiera wyłącznie tekst startowy Boosta, więc po boost:install zastąp go. Claude Code 2.1.277 i nowsze czytają też AGENTS.md bezpośrednio, gdy projekt nie ma CLAUDE.md (sesje first-party, oba kanały wydań, stan na 2026-10-12); import działa w każdej wersji i na każdym kanale.

Wspólne reguły agentów wyjaśniają, jak utrzymać jedno źródło prawdy, gdy zespół używa wszystkich trzech narzędzi.

Jak zbudować endpoint Laravel z cienkim kontrolerem, którego agent nie oszuka?

Dział zatytułowany „Jak zbudować endpoint Laravel z cienkim kontrolerem, którego agent nie oszuka?”

Prośba o „API produktów” kończy się 200-liniowym kontrolerem z budowaniem zapytań, walidacją i kształtowaniem odpowiedzi w jednym miejscu, a do tego testami dopisanymi po fakcie tak, żeby pasowały do tego, co robi kod. Odwróć kolejność. Spisz kryteria akceptacji, każ agentowi zamienić je w padające testy funkcjonalne, przejrzyj tylko testy, a potem pozwól mu implementować pod testy, których nie wolno mu edytować. Samo pisanie kryteriów opisuje strona kryteria akceptacji.

Czytasz testy, a nie implementację, która po nich przyjdzie. Następnie:

Akceptujesz kontroler, który wyłącznie deleguje:

app/Http/Controllers/Api/V1/ProductController.php
final class ProductController extends Controller
{
public function __construct(private readonly ProductService $products) {}
public function index(IndexProductRequest $request): AnonymousResourceCollection
{
return ProductResource::collection(
$this->products->paginate(
perPage: $request->integer('per_page', 15),
search: $request->string('search')->toString(),
),
);
}
}

RefreshDatabase uruchamia migracje przed testami, więc model wygenerowany bez migracji pada tutaj, a nie na produkcji.

Jak powstrzymać agenta przed ogłoszeniem „gotowe”, zanim bramka przejdzie?

Dział zatytułowany „Jak powstrzymać agenta przed ogłoszeniem „gotowe”, zanim bramka przejdzie?”

Instrukcja w AGENTS.md to rada, a hook albo check w CI to bramka. Claude Code dostaje lokalną bramkę z poniższego hooka Stop. Codex też ma hooki ze zdarzeniem Stop (codex-cli 0.157.1, wymagają utrwalonego zaufania), a Cursor ma Hooks, ale poniższe konfiguracje dają tym dwóm narzędziom prompt i regułę, więc dla nich bramką jest job CI pod zakładkami.

Hook Stop uruchamia się, gdy Claude kończy odpowiedź. Kod wyjścia 2 blokuje zakończenie i pokazuje Claude’owi stderr hooka, więc Claude pracuje dalej:

.claude/hooks/php-gate.sh
#!/usr/bin/env bash
# Refuse "done" until Pint, PHPStan and PHPUnit pass.
input=$(cat)
# Already continuing because of this hook: stop and report instead of looping.
[ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ] && exit 0
cd "$CLAUDE_PROJECT_DIR" || exit 0
# Skip only when the branch has no code, test or schema changes against its base,
# committed, uncommitted or untracked: committing during the turn does not hide work.
dirs="app src tests database config routes"
base=$(git merge-base HEAD origin/HEAD 2>/dev/null || git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD)
if git diff --quiet "$base" -- $dirs && [ -z "$(git ls-files --others --exclude-standard -- $dirs)" ]; then exit 0; fi
if ! out=$(vendor/bin/pint --test 2>&1); then
echo "Pint found style violations. Run vendor/bin/pint --dirty:" >&2
echo "$out" | tail -20 >&2; exit 2
fi
if ! out=$(vendor/bin/phpstan analyse --no-progress --error-format=raw 2>&1); then
echo "PHPStan failed. Fix the code; never add ignoreErrors or @phpstan-ignore:" >&2
echo "$out" | head -30 >&2; exit 2
fi
if ! out=$(php artisan test 2>&1); then
echo "Tests failed. Fix app/, never tests/:" >&2
echo "$out" | tail -40 >&2; exit 2
fi
exit 0

Skrypt potrzebuje jq; w Symfony zamień php artisan test na vendor/bin/phpunit. Warunek pominięcia porównuje gałąź z jej bazą (origin/HEAD, a w razie braku origin/main), więc praca, którą agent zacommituje w trakcie tury, i tak zostanie sprawdzona. Koszt: na gałęzi, która ma już zmiany w PHP, bramka uruchamia się w każdej turze, także przy samych pytaniach. Jeśli żadna z tych referencji zdalnych nie istnieje, skrypt wraca do sprawdzania tylko niezacommitowanych zmian. Ponieważ kończy się kodem 0, gdy stop_hook_active ma wartość true, padająca bramka wymusza jedną ponowną próbę, a potem Claude się zatrzymuje i raportuje, zamiast kręcić się w pętli na teście, który nie może przejść. Zarejestruj skrypt w .claude/settings.json i nadaj mu prawo wykonania (chmod +x):

.claude/settings.json
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/php-gate.sh", "timeout": 600 } ] }
]
}
}

Automatyzacja hookami opisuje liczenie ponownych prób i pozostałe zdarzenia.

Niezależnie od tego, co działa lokalnie, decyduje CI. Ten job działa z tokenem tylko do odczytu i nie przechodzi, gdy pull request zmienia istniejący test bez etykiety od recenzenta:

.github/workflows/php-gates.yml
name: php-gates
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
jobs:
gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
fetch-depth: 2 # the PR merge commit and its base parent
- name: Existing tests unchanged
if: ${{ !contains(github.event.pull_request.labels.*.name, 'test-change-approved') }}
run: |
changed=$(git diff --name-only --diff-filter=MDR HEAD^1 HEAD -- tests/)
if [ -n "$changed" ]; then
echo "Existing tests modified, moved or deleted; a reviewer must add the test-change-approved label:"
echo "$changed"; exit 1
fi
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- run: composer install --no-interaction --no-progress --prefer-dist
- run: composer audit
- run: vendor/bin/pint --test
- run: vendor/bin/phpstan analyse --no-progress
- run: cp .env.example .env && php artisan key:generate
- run: php artisan test

Nowe pliki testów przechodzą krok „Existing tests unchanged”; edycje, zmiany nazw i usunięcia w tests/ powodują, że ten krok nie przechodzi, dopóki człowiek ich nie przejrzy i nie doda etykiety test-change-approved, która startuje nowe uruchomienie. Krok jest bramką tylko wtedy, gdy pull request nie może go usunąć: GitHub uruchamia plik workflow z samego pull requesta, więc obejmij /.github/ regułą CODEOWNERS i wymuś tę recenzję przez ochronę gałęzi lub ruleset. Ochrona wyroczni idzie dalej: reguły deny w ogóle nie pozwalają agentowi edytować testów.

Jak wyłapać zapytania N+1 w Eloquencie pisanym przez agenta?

Dział zatytułowany „Jak wyłapać zapytania N+1 w Eloquencie pisanym przez agenta?”

Typowa regresja w Laravelu pisanym przez agenta to resource albo pętla w Blade, która leniwie dociąga relację dla każdego wiersza: szybko przy pięciu wierszach na demo, timeout przy 500 na produkcji. Zamień to w wyjątek wszędzie poza produkcją:

app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Throws on lazy loading, on assigning non-fillable attributes and on reading missing attributes.
Model::shouldBeStrict(! $this->app->isProduction());
}

Przy włączonym trybie ścisłym każdy test, który dotyka leniwie ładowanej relacji, rzuca LazyLoadingViolationException, a hook Stop i job CI powyżej zamieniają to w wywróconą bramkę. Tryb ścisły nie złapie zapytania na wiersz, które nie jest relacją, na przykład count() w akcesorze, więc przypnij też liczbę zapytań każdego endpointu z listą:

Jak dodać cache i nie serwować nieaktualnych danych?

Dział zatytułowany „Jak dodać cache i nie serwować nieaktualnych danych?”

Naiwny prompt o cache rozsiewa Cache::remember() po całym kodzie bez żadnego unieważniania, a strona produktu pokazuje cenę sprzed tygodnia. Niech unieważnianie i test, który je udowadnia, będą częścią prośby:

Tagi cache działają tylko w magazynach, które je obsługują, takich jak Redis i Memcached; magazyny file i database rzucają wyjątek. Uruchom test na magazynie, którego używasz na produkcji, a nie na magazynie array, który domyślnie ustawia phpunit.xml.

Jak zmodernizować legacy z mysql_* przy pomocy agenta?

Dział zatytułowany „Jak zmodernizować legacy z mysql_* przy pomocy agenta?”

Rozszerzenie mysql_* usunięto w PHP 7.0, więc kod, który wciąż go wywołuje, działa tylko na PHP 5.x. Konwersja składni jest mechaniczna, więc agent robi ją szybko; ryzyko leży gdzie indziej. Agent może „unowocześnić” składnię, zostawiając "... WHERE id = $id" wewnątrz wywołania PDO, albo po cichu zmienić to, co funkcja zwraca w przypadku brzegowym, na którym polegają wywołujący. Dlatego najpierw przypnij dzisiejsze zachowanie testami charakteryzującymi i zablokuj je przed przepisaniem. Starego kodu nie uruchomisz na PHP 8.4, którego używa reszta tej strony, więc nagraj jego zachowanie tam, gdzie wciąż działa: wywołaj funkcję dla każdego przypadku w kontenerze z PHP 5.6 (oficjalny obraz php:5.6-cli plus docker-php-ext-install mysql, podłączony do jednorazowej kopii bazy) i zapisz zwracane wartości jako fixture w JSON. Testy działają potem na PHP 8.4 i sprawdzają, że nowy kod zwraca to, co zapisano w fixture.

Uruchom z katalogu głównego repozytorium, żeby Claude Code przeszukał wszystkich wywołujących, a nie tylko otwarty plik:

Okno terminala
claude "Modernize get_user() in legacy/users.php. First record its current behaviour: write scripts/record-get-user.php, which calls get_user() for an existing id, a missing id, id 0 and a non-numeric string and writes the return values to tests/Characterization/fixtures/get_user.json; run it in a php:5.6-cli container with the mysql extension against a disposable copy of the test database, and show me the fixture. Then write tests/Characterization/GetUserTest.php, which runs on PHP 8.4 and asserts that the new code returns data equivalent to those recorded values (same fields, same result for a missing id). Then create App\\Repository\\UserRepository::findById(int \$id): ?User using PDO prepared statements with bound parameters, wrap PDOException in RepositoryException, update every caller repo-wide, and make the characterization tests pass against the new code without editing them. Finish with vendor/bin/phpstan analyse and the full test suite."

Docelowy kształt ma związany parametr i typowany zwrot:

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]);
$row = $statement->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : User::fromArray($row);
} catch (PDOException $e) {
throw new RepositoryException("Failed to fetch user {$id}", 0, $e);
}
}
}

Przed merge’em zrób wstępne przeszukanie pod kątem SQL, który wciąż buduje zapytanie ze zmiennej. Ten grep oznacza wywołania budujące zapytania (PDO, mysqli i metody surowego SQL w Laravelu), których lista argumentów zawiera zmienną ($). Konkatenacji ze stałą albo wywołaniem funkcji nie oznacza:

Okno terminala
grep -rnE '(query|prepare|exec|select|statement|whereRaw|selectRaw|orderByRaw)\s*\([^)]*\$' app/ src/ legacy/

Trafienie to linia do poprawienia albo uzasadnienia; pusty wynik nie jest dowodem. Grep nie widzi zapytania zbudowanego linię wcześniej, heredoca ani wartości przekazanej z innej funkcji. Prawdziwą bramką jest analiza przepływu danych w CI: analiza taint w Psalmie (vendor/bin/psalm --taint-analysis) śledzi dane z żądania aż do miejsc wykonujących SQL, takich jak PDO::exec(), a reguła Semgrep robi to samo dla twoich helperów zapytań. Razem z testami charakteryzującymi powyżej to dowód, który recenzent przyjmuje. Szerszą strategię (seamy typu strangler, kolejność dużego przepisywania) opisuje strona modernizacja legacy.

Skąd wiesz, że testy PHPUnit od agenta złapałyby błąd?

Dział zatytułowany „Skąd wiesz, że testy PHPUnit od agenta złapałyby błąd?”

Agenci piszą testy, które przechodzą. To, czy padają, gdy kod jest błędny, to osobne pytanie, a dwie zmiany w PHPUnit sprawiają, że testy agenta są po cichu słabsze, niż wyglądają:

  • Adnotacje już się nie uruchamiają. PHPUnit 12 usunął metadane w komentarzach dokumentujących. Metoda annotation_only() z samym /** @test */ nad nią nie jest w PHPUnit 13 testem: runner pomija ją bez błędu, więc liczba testów po prostu spada.
  • Prophecy nie jest w zestawie. PHPUnit 10 i nowsze nie mają integracji z Prophecy; ProphecyTrait pochodzi z pakietu phpspec/prophecy-phpunit. Bez niego klasa testu się nie wczyta. Własne createMock() i createStub() z PHPUnit nie potrzebują niczego dodatkowego.

Proś o poniższy kształt. Order to tutaj zwykły obiekt wartości z domeny z konstruktorem (int $id, OrderStatus $status), a nie model Eloquenta, którego konstruktor przyjmuje tablicę atrybutów. Z taką klasą test przechodzi na PHPUnit 13.3.5 bez żadnej deprecjacji. Zwróć uwagę na expects() przed with() przy find: PHPUnit 13 oznacza with() na mocku bez expects() jako przestarzałe, a PHPUnit 14 je odrzuci.

tests/Unit/OrderServiceTest.php
declare(strict_types=1);
namespace Tests\Unit;
use App\Domain\Order;
use App\Enums\OrderStatus;
use App\Exceptions\InvalidOrderStateException;
use App\Repositories\OrderRepository;
use App\Services\{NotificationService, OrderService};
use PHPUnit\Framework\Attributes\{DataProvider, Test};
use PHPUnit\Framework\TestCase;
final class OrderServiceTest extends TestCase
{
#[Test]
public function it_completes_a_processing_order_and_notifies_once(): void
{
$order = new Order(123, OrderStatus::Processing);
$orders = $this->createMock(OrderRepository::class);
$orders->expects($this->once())->method('find')->with(123)->willReturn($order);
$orders->expects($this->once())->method('save')->with($order);
$notifications = $this->createMock(NotificationService::class);
$notifications->expects($this->once())->method('sendOrderCompleted')->with($order);
$result = (new OrderService($orders, $notifications))->completeOrder(123);
$this->assertSame(OrderStatus::Completed, $result->status);
}
#[Test]
#[DataProvider('nonCompletableStatuses')]
public function it_refuses_to_complete_from(OrderStatus $status): void
{
$orders = $this->createStub(OrderRepository::class);
$orders->method('find')->willReturn(new Order(123, $status));
$notifications = $this->createMock(NotificationService::class);
$notifications->expects($this->never())->method('sendOrderCompleted');
$this->expectException(InvalidOrderStateException::class);
(new OrderService($orders, $notifications))->completeOrder(123);
}
public static function nonCompletableStatuses(): array
{
return [
'completed' => [OrderStatus::Completed],
'cancelled' => [OrderStatus::Cancelled],
'refunded' => [OrderStatus::Refunded],
];
}
}

Testy mutacyjne odpowiadają na to pytanie wprost. Infection zmienia twój kod (odwraca warunek, usuwa wywołanie) i ponownie uruchamia testy; mutant, który przeżyje, to błąd, którego twoje testy nie złapią. Potrzebuje sterownika pokrycia (PCOV lub Xdebug). Uruchom go na liniach zmienionych przez agenta:

Okno terminala
vendor/bin/infection --git-diff-filter=AM --git-diff-base=origin/main --min-msi=80 --threads=max

Tech lead zatwierdza zmiany w testach i próg wyniku mutacyjnego, a nie każdą linię implementacji. Jak dobrać ten próg, opisuje strona siła wyroczni.

Receptury się przenoszą, zmieniają się prymitywy. Symfony 8.1 wymaga PHP 8.4.1, a 7.4 to gałąź długoterminowego wsparcia dla zespołów, które nie nadążają za wydaniami co pół roku. Generuj kod przez php bin/console make:* (z symfony/maker-bundle) zamiast pisać go ręcznie, a migracje generuj z różnicy encji przez php bin/console doctrine:migrations:diff, zamiast pozwalać agentowi pisać SQL. Leniwe ładowanie w Doctrine nie rzuca wyjątku tak jak ścisły Eloquent, więc test liczby zapytań jest twoją jedyną bramką na N+1. Sprawdzaj ją w teście funkcjonalnym przez kolektor Doctrine w profilerze:

tests/Controller/ProductIndexTest.php
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class ProductIndexTest extends WebTestCase
{
public function testListQueryCountStaysFlat(): void
{
$client = static::createClient();
// Seed 40+ products here with your fixtures or factories.
$client->enableProfiler();
$client->request('GET', '/api/products');
self::assertResponseIsSuccessful();
$profile = $client->getProfile();
self::assertNotFalse($profile, 'The profiler is not enabled in the test environment.');
self::assertLessThanOrEqual(3, $profile->getCollector('db')->getQueryCount());
}
}

getProfile() zwraca false, jeśli profiler nie jest włączony w środowisku test; recepta symfony/web-profiler-bundle ustawia tam framework.profiler.collect: false, a enableProfiler() włącza zbieranie danych tylko dla następnego żądania. Granicę (tu 3) wyznacz, mierząc endpoint przy kilku wierszach, potem utwórz ich znacznie więcej i zostaw asercję bez zmian. Testy uruchamia się przez vendor/bin/phpunit, więc wstaw to polecenie do hooka Stop i joba CI zamiast php artisan test.

Czy ten artykuł Ci pomógł?

Jedno kliknięcie, bez logowania.

wcale bardzo