Skip to content

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

Terminal window
# From your repo root
git clone https://github.com/pace-framework-org/pace-framework-starter pace
cd pace
python -m venv .venv && source .venv/bin/activate
pip install PyYAML jsonschema anthropic

2 — 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-6

Test command variants

Scenariotest_command
PHPUnit (all tests)php artisan test --parallel
Pest (all tests)./vendor/bin/pest
Specific test suitephp artisan test --testsuite=Unit
Single test filephp 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

Terminal window
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

Terminal window
cd pace
python pace/orchestrator.py --day 1

What FORGE does with PHP

FORGE’s tool-calling loop will:

  1. Read composer.json to understand autoload namespaces (autoload.psr-4) and installed packages
  2. Read existing source files under app/ to learn the project’s naming conventions and folder structure
  3. Write new .php files using Eloquent, service patterns, and Pest/PHPUnit
  4. Run the build_command (Pint + PHPStan) to catch formatting and type errors before running tests
  5. Run php artisan test and 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 $fillable definitions
  • Secrets in .env committed to git: .env files that appear in git ls-files output
  • Unsafe deserialization: unserialize() called with user-supplied input
  • Missing CSRF protection: routes registered outside the web middleware group that accept POST, PUT, or DELETE requests without explicit csrf exclusion justification
  • XSS via unescaped Blade output: {!! $var !!} used where {{ $var }} is correct
  • Authentication gaps: sensitive routes missing the auth middleware

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:

Terminal window
pecl install pcov

Add 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.

ToolPurposeCommand
Laravel PintCode style enforcement./vendor/bin/pint --test
PHPStanStatic analysis./vendor/bin/phpstan analyse app/ --level=6
CombinedFull 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:

Terminal window
./vendor/bin/phpstan --generate-baseline

9 — 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:

Terminal window
composer require testcontainers/testcontainers --dev

Mark 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:

pest.php
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 $fillable list.
  • unserialize() with user data: PHP’s unserialize() can instantiate arbitrary classes. Remediation is to switch to json_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.