PACE with PHP (Laravel)
This tutorial shows how to configure PACE for a Laravel application. FORGE will write idiomatic PHP code following Laravel conventions, GATE will run your PHPUnit or Pest test suite, and SENTINEL will audit for PHP-specific security risks including SQL injection, mass assignment vulnerabilities, and CSRF gaps.
Prerequisites
- PHP 8.2 or later
- Composer
- Laravel 11
- PHPUnit or Pest (both are supported — Pest is recommended for new projects)
- An API key for your LLM provider
Project layout assumed by this tutorial
my-api/├── pace/│ └── pace.config.yaml├── app/│ ├── Http/Controllers/│ ├── Models/│ └── Services/├── tests/│ ├── Unit/│ └── Feature/├── composer.json└── .pace/1 — Install PACE
# From your repo rootgit clone https://github.com/pace-framework-org/pace-framework-starter pacecd pacepython -m venv .venv && source .venv/bin/activatepip install PyYAML jsonschema anthropic2 — Configure for PHP
Edit pace/pace.config.yaml:
framework_version: "1.0"
product: name: "My API" description: > A Laravel 11 REST API. Business logic lives in service classes, never in controllers. Models use Eloquent; repository classes wrap Eloquent for testability. All method parameters and return types are annotated. Code must pass Pint formatting and PHPStan level 6. Use Pest for new test files, with describe() and it() blocks. Tests that touch the database use the RefreshDatabase trait. github_org: "my-org"
sprint: duration_days: 14
source: dirs: - name: "app" path: "app/" language: "PHP" description: "Laravel controllers, Eloquent models, and service classes" - name: "tests" path: "tests/" language: "PHP" description: "PHPUnit/Pest unit and feature tests"
tech: primary_language: "PHP 8.2 / Laravel 11" test_command: "php artisan test --parallel" build_command: "./vendor/bin/pint --test && ./vendor/bin/phpstan analyse app/ --level=6"
platform: ci: local
llm: provider: anthropic model: claude-sonnet-4-6Test command variants
| Scenario | test_command |
|---|---|
| PHPUnit (all tests) | php artisan test --parallel |
| Pest (all tests) | ./vendor/bin/pest |
| Specific test suite | php artisan test --testsuite=Unit |
| Single test file | php artisan test tests/Unit/OrderTest.php |
| With coverage (Xdebug/PCOV) | php artisan test --parallel --coverage --min=80 |
3 — FORGE hints for PHP
The product.description field is the primary way to steer FORGE toward the right PHP and Laravel idioms. Include the following guidance:
product: description: > A Laravel 11 REST API. Follow these conventions exactly: Use Laravel service classes for business logic, never in controllers. All models use Eloquent. Repository pattern wraps Eloquent models. Tests use Laravel's RefreshDatabase trait for any test that touches the database. Use Pest for new test files. Group with describe() and it() blocks. Type-annotate all method parameters and return types. Code must pass Pint formatting and PHPStan level 6.Key hints and why they matter:
- “Use Laravel service classes for business logic, never in controllers” — prevents FORGE from generating fat controllers that are hard to test in isolation.
- “All models use Eloquent. Repository pattern wraps Eloquent models” — keeps FORGE from mixing raw PDO or query builder calls inconsistently.
- “Tests use Laravel’s RefreshDatabase trait for database tests” — ensures test isolation without FORGE having to figure out teardown logic.
- “Use Pest for new test files. Group with describe() and it() blocks” — Pest’s syntax is more expressive; without this hint FORGE defaults to PHPUnit class syntax.
- “Type-annotate all method parameters and return types” — required for PHPStan level 6 to pass without a baseline.
4 — Set credentials
export ANTHROPIC_API_KEY="sk-ant-..."5 — Write a PHP sprint plan
Create plan.yaml at your repo root:
release: v1.0
stories: - id: story-1 title: "Order Eloquent model, migration, and repository interface" status: pending acceptance_criteria: - "Order model has id, user_id, total (decimal), and status (enum) fields" - "OrderRepository interface defines create(), findById(), and findByUser() methods" - "InMemoryOrderRepository implements OrderRepository for unit testing" - "Unit tests cover save-and-retrieve round-trip" - "php artisan test exits 0" out_of_scope: - "HTTP endpoints" - "Payment processing"
- id: story-2 title: "OrderService with validation and status transitions" status: pending acceptance_criteria: - "place() throws InvalidOrderException for an order with zero total" - "cancel() throws OrderAlreadyShippedException when order status is 'shipped'" - "Pest tests use Mockery to mock OrderRepository — no database required" - "php artisan test exits 0"
- id: story-3 title: "POST /orders and GET /orders/{id} endpoints" status: pending acceptance_criteria: - "POST /orders returns 201 with the created order as JSON" - "GET /orders/{id} returns 200 with the order or 404 when not found" - "Feature tests use Laravel's TestCase with RefreshDatabase" - "php artisan test exits 0"6 — Run Day 1
cd pacepython pace/orchestrator.py --day 1What FORGE does with PHP
FORGE’s tool-calling loop will:
- Read
composer.jsonto understand autoload namespaces (autoload.psr-4) and installed packages - Read existing source files under
app/to learn the project’s naming conventions and folder structure - Write new
.phpfiles using Eloquent, service patterns, and Pest/PHPUnit - Run the
build_command(Pint + PHPStan) to catch formatting and type errors before running tests - Run
php artisan testand read failures to self-correct
A typical FORGE run for Day 1:
[FORGE] Reading composer.json ...[FORGE] Reading app/Models/ (directory listing) ...[FORGE] Writing app/Models/Order.php ...[FORGE] Writing app/Repositories/OrderRepository.php ...[FORGE] Writing app/Repositories/InMemoryOrderRepository.php ...[FORGE] Writing tests/Unit/OrderRepositoryTest.php ...[FORGE] Running build: ./vendor/bin/pint --test && ./vendor/bin/phpstan analyse app/ --level=6 ...[FORGE] Running tests: php artisan test --parallel ...[FORGE] All tests pass. Calling complete_handoff.What SENTINEL checks for PHP
SENTINEL applies PHP and Laravel-specific checks:
- SQL injection: raw queries using
DB::statement("...{$id}")or$wpdb->query("...{$id}")with interpolated variables instead of bindings - Mass assignment vulnerabilities: models with
$guarded = []or missing$fillabledefinitions - Secrets in
.envcommitted to git:.envfiles that appear ingit ls-filesoutput - Unsafe deserialization:
unserialize()called with user-supplied input - Missing CSRF protection: routes registered outside the
webmiddleware group that acceptPOST,PUT, orDELETErequests without explicitcsrfexclusion justification - XSS via unescaped Blade output:
{!! $var !!}used where{{ $var }}is correct - Authentication gaps: sensitive routes missing the
authmiddleware
7 — Test coverage
Configure coverage drivers
PHP supports two coverage drivers: Xdebug and PCOV. PCOV is significantly faster for CI because it only instruments code paths actually executed, with no debugger overhead.
Install PCOV:
pecl install pcovAdd coverage reporting to phpunit.xml:
<coverage processUncoveredFiles="true"> <include> <directory>app/</directory> </include> <report> <clover outputFile="coverage.xml"/> </report></coverage>Enforce a coverage threshold
Update test_command to fail the build when coverage falls below 80 %:
tech: test_command: "php artisan test --parallel --coverage --min=80"8 — Linter and formatter
Laravel ships with Laravel Pint for code formatting and integrates well with PHPStan for static analysis.
| Tool | Purpose | Command |
|---|---|---|
| Laravel Pint | Code style enforcement | ./vendor/bin/pint --test |
| PHPStan | Static analysis | ./vendor/bin/phpstan analyse app/ --level=6 |
| Combined | Full build check | ./vendor/bin/pint --test && ./vendor/bin/phpstan analyse app/ --level=6 |
Use the combined command as your build_command:
tech: build_command: "./vendor/bin/pint --test && ./vendor/bin/phpstan analyse app/ --level=6"Add the PHPStan constraint to product.description so FORGE targets the correct level:
product: description: > ... Code must pass Pint formatting and PHPStan level 6. ...If FORGE-generated code triggers PHPStan errors that are false positives, generate a baseline rather than lowering the level:
./vendor/bin/phpstan --generate-baseline9 — Database testing patterns
RefreshDatabase with SQLite (fast, default)
Configure an in-memory SQLite database for unit and feature tests in phpunit.xml:
<php> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/></php>Any test class that touches the database should include the trait:
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderFeatureTest extends TestCase{ use RefreshDatabase;
public function test_creates_order(): void { // database is clean for every test method }}Testcontainers for MySQL or PostgreSQL
When you need to test against the real database engine, use testcontainers/testcontainers-php:
composer require testcontainers/testcontainers --devMark slow integration tests with @group integration and exclude them from the default test_command:
tech: test_command: "php artisan test --parallel --exclude-group=integration"Run the integration suite separately in CI on a dedicated job that spins up Docker.
10 — Mock patterns
Mockery with Pest
it('throws when order total is zero', function () { $repo = Mockery::mock(OrderRepository::class); $service = new OrderService($repo); expect(fn() => $service->place(userId: 1, total: 0)) ->toThrow(InvalidOrderException::class);});Add Mockery cleanup to pest.php so mocks are verified and torn down after every test:
uses()->afterEach(fn() => Mockery::close())->in('tests/Unit');11 — Multi-day sprint
PACE carries context between days through .pace/context/engineering.md. After Day 1 ships, FORGE reads this file to understand what was already built — interfaces defined, models created, service contracts established — before writing Day 2 code.
A typical Day 2 start:
[FORGE] Reading .pace/context/engineering.md ...[FORGE] Found: app/Models/Order.php (Order model, OrderStatus enum)[FORGE] Found: app/Repositories/OrderRepository.php (interface)[FORGE] Found: app/Repositories/InMemoryOrderRepository.php (in-memory implementation)[FORGE] Writing app/Services/OrderService.php ...[FORGE] Writing tests/Unit/OrderServiceTest.php ...FORGE builds OrderService against the OrderRepository interface it already knows about, not against the concrete Eloquent implementation, keeping the service layer testable without a database.
12 — Advisory clearance story
Once SENTINEL has flagged advisories across earlier stories, add a dedicated clearance story so the team explicitly resolves or escalates each one:
- id: story-5 title: "Security and DevOps advisory clearance" status: pending acceptance_criteria: - "All open SENTINEL advisories resolved or escalated with written justification" - "All CONDUIT advisories resolved or escalated" - "php artisan test exits 0 after any remediation"Common PHP SENTINEL advisories that end up in clearance stories:
- Mass assignment with
$guarded = []: SENTINEL flags this because an empty guard list means any field can be mass-assigned. Remediation is to replace with an explicit$fillablelist. unserialize()with user data: PHP’sunserialize()can instantiate arbitrary classes. Remediation is to switch tojson_decode()or use a signed payload.
13 — CI build caching
Cache Composer’s package downloads to speed up CI runs:
- name: Get Composer cache directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-composer-Place these steps before composer install in your workflow. The cache key includes the hash of composer.lock so the cache invalidates automatically when any dependency version changes.
14 — Windows notes
On Windows, use php artisan test directly — the --parallel flag works but requires WSL2 or Git Bash for process forking on some PHP builds. PCOV requires a pre-built DLL; on Windows CI, Xdebug is more reliable and easier to install via shivammathur/setup-php. Forward slashes in source.dirs paths work on all platforms including Windows.
Common issues
php: command not found
PACE runs test_command from the repo root using the shell’s PATH. Ensure PHP is on PATH in the environment that runs the orchestrator. If using phpenv or similar version managers, activate the correct version before running PACE.
Class not found after FORGE writes a new file
Laravel uses Composer’s autoloader. After FORGE creates new classes, run composer dump-autoload to regenerate the autoload map. If this happens repeatedly, check that autoload.psr-4 in composer.json covers the directory FORGE is writing to.
FORGE writes to the wrong namespace
FORGE infers namespaces from composer.json’s autoload.psr-4 section. If the namespace is wrong, verify that the path-to-namespace mapping is correct and add a sentence to source.dirs[].description such as “Namespace root is App\. Controllers live in App\Http\Controllers\, services in App\Services\.”
PHPStan fails on FORGE-generated code
If PHPStan reports errors that are structural false positives (e.g., dynamic property access on Eloquent models), generate a baseline with ./vendor/bin/phpstan --generate-baseline and commit it. Include the target level in product.description so FORGE writes code that already satisfies it.
RefreshDatabase causes test isolation issues
Ensure every test class that touches the database uses the RefreshDatabase trait. Never mix :memory: SQLite and a real database connection in the same test suite — the transactions that RefreshDatabase relies on behave differently across drivers and can leave stale data.