Laravel 12 Starter Kit: Testing, Static Analysis & Quality Gates
How to set up a modern Laravel 12 project with comprehensive testing, static analysis, and automated quality gates from day one.
Robert Fridzema
Fullstack Developer

Starting a Laravel project "the right way" saves countless hours later. This guide walks through setting up a Laravel 12 project with comprehensive quality tooling: Pest for testing, PHPStan for static analysis, and automated quality gates that prevent bugs from reaching production.
The Goal
By the end, your project will have:
- Pest for expressive, fast testing
- PHPStan at level 8+ for type safety
- Pint for consistent code style
- Type coverage enforcement
- Mutation testing with Infection
- Git hooks that prevent bad commits
- CI/CD that blocks bad PRs
Initial Setup
Start with a fresh Laravel 12 installation:
composer create-project laravel/laravel my-project cd my-project
Testing with Pest
Pest provides a clean, expressive syntax for tests. Install it:
composer require pestphp/pest --dev composer require pestphp/pest-plugin-laravel --dev php artisan pest:install
Configure Pest
// tests/Pest.php <?php uses(Tests\TestCase::class)->in('Feature'); uses(Tests\TestCase::class)->in('Unit'); // Global helpers function createUser(array $attributes = []): User { return User::factory()->create($attributes); } function actingAsUser(array $attributes = []): TestCase { return test()->actingAs(createUser($attributes)); }
Write Expressive Tests
// tests/Feature/OrderTest.php <?php use App\Models\Order; use App\Models\User; describe('Orders', function () { it('can be created by authenticated users', function () { $user = createUser(); $response = $this->actingAs($user) ->post('/orders', [ 'product_id' => 1, 'quantity' => 2, ]); $response->assertCreated(); expect(Order::count())->toBe(1); expect(Order::first()) ->user_id->toBe($user->id) ->quantity->toBe(2); }); it('requires authentication', function () { $this->post('/orders', ['product_id' => 1]) ->assertUnauthorized(); }); it('validates required fields', function () { actingAsUser() ->post('/orders', []) ->assertInvalid(['product_id', 'quantity']); }); });
Parallel Testing
Speed up your test suite:
composer require brianium/paratest --dev
// composer.json "scripts": { "test": "pest --parallel" }
Static Analysis with PHPStan
PHPStan catches bugs before runtime. Install and configure:
composer require phpstan/phpstan --dev composer require larastan/larastan --dev
# phpstan.neon includes: - vendor/larastan/larastan/extension.neon parameters: level: 8 paths: - app/ excludePaths: - app/Console/Kernel.php ignoreErrors: # Add specific ignores here if needed checkMissingIterableValueType: true checkGenericClassInNonGenericObjectType: true reportUnmatchedIgnoredErrors: true
Level 8 Tips
Level 8 is strict. Here's how to make it work:
// Bad - PHPStan doesn't know the type $user = User::find($id); $user->name; // Error: Cannot access property on mixed // Good - handle null case $user = User::findOrFail($id); $user->name; // OK // Or with null check $user = User::find($id); if ($user !== null) { $user->name; // OK }
// Bad - array without type info /** @var array $items */ $items = $this->getItems(); // Good - specify array contents /** @var array<int, OrderItem> $items */ $items = $this->getItems(); // Best - use collections /** @return Collection<int, OrderItem> */ public function getItems(): Collection { return $this->items; }
Type Coverage
Enforce that all code has type declarations:
composer require pestphp/pest-plugin-type-coverage --dev
// tests/Pest.php pest()->extend(Tests\TestCase::class) ->beforeAll(function () { // Require 100% type coverage }) ->in('Feature', 'Unit');
// composer.json "scripts": { "test:types": "pest --type-coverage --min=100" }
Fix type issues:
// Before - no return type public function calculate($amount) { return $amount * 1.21; } // After - fully typed public function calculate(float $amount): float { return $amount * 1.21; }
Code Style with Pint
Laravel Pint ensures consistent formatting:
composer require laravel/pint --dev
// pint.json { "preset": "laravel", "rules": { "declare_strict_types": true, "final_class": true, "ordered_class_elements": true, "no_unused_imports": true } }
// composer.json "scripts": { "lint": "pint --test", "lint:fix": "pint" }
Mutation Testing
Mutation testing ensures your tests actually verify behavior:
composer require infection/infection --dev
// infection.json5 { "source": { "directories": ["app"] }, "logs": { "text": "infection.log", "summary": "infection-summary.log" }, "mutators": { "@default": true }, "minMsi": 80, "minCoveredMsi": 90 }
// composer.json "scripts": { "test:mutation": "infection --threads=4" }
Mutation testing modifies your code and runs tests. If tests still pass after a mutation, you have a gap:
// Your code public function calculateDiscount(int $quantity): float { if ($quantity >= 10) { return 0.1; } return 0; } // Mutation: changed >= to > if ($quantity > 10) { // Tests should fail! return 0.1; } // If tests pass, you need a test for quantity = 10 it('applies discount for exactly 10 items', function () { expect(calculateDiscount(10))->toBe(0.1); });
Git Hooks with Captain Hook
Prevent bad commits automatically:
composer require captainhook/captainhook --dev vendor/bin/captainhook install
// captainhook.json { "pre-commit": { "actions": [ { "action": "vendor/bin/pint --test" }, { "action": "vendor/bin/phpstan analyse" } ] }, "pre-push": { "actions": [ { "action": "vendor/bin/pest --parallel" } ] } }
Now:
- Every commit runs Pint and PHPStan
- Every push runs the full test suite
- Bad code never reaches the repo
CI/CD Pipeline
GitHub Actions
# .github/workflows/ci.yml name: CI on: push: branches: [main] pull_request: branches: [main] jobs: tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.4' coverage: xdebug - name: Install dependencies run: composer install --no-progress - name: Check code style run: vendor/bin/pint --test - name: Run PHPStan run: vendor/bin/phpstan analyse - name: Run tests run: vendor/bin/pest --parallel --coverage --min=80 - name: Check type coverage run: vendor/bin/pest --type-coverage --min=100 mutation: runs-on: ubuntu-latest needs: tests steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.4' coverage: xdebug - name: Install dependencies run: composer install --no-progress - name: Run mutation tests run: vendor/bin/infection --threads=4 --min-msi=80
Complete Scripts
Your composer.json scripts section:
{ "scripts": { "lint": "pint --test", "lint:fix": "pint", "analyse": "phpstan analyse", "test": "pest --parallel", "test:coverage": "pest --parallel --coverage --min=80", "test:types": "pest --type-coverage --min=100", "test:mutation": "infection --threads=4", "quality": [ "@lint", "@analyse", "@test:coverage", "@test:types" ], "quality:full": [ "@quality", "@test:mutation" ] } }
Run composer quality before every PR.
Recommended Directory Structure
app/ ├── Actions/ # Single-purpose action classes ├── Data/ # DTOs (Data Transfer Objects) ├── Enums/ # PHP 8.1+ enums ├── Exceptions/ # Custom exceptions ├── Http/ │ ├── Controllers/ │ ├── Middleware/ │ └── Requests/ # Form requests with validation ├── Models/ ├── Policies/ ├── Repositories/ # Data access layer (optional) └── Services/ # Business logic tests/ ├── Feature/ # Integration tests ├── Unit/ # Unit tests └── Pest.php # Pest configuration
Key Takeaways
- Start strict - It's easier to maintain standards than to add them later
- Automate everything - Git hooks and CI catch issues before humans need to
- Type everything - PHP 8.4's type system is powerful; use it
- Test behavior, not implementation - Mutation testing reveals weak tests
- Make it fast - Parallel testing and caching keep feedback loops tight
This setup has served me well across dozens of Laravel projects. The initial investment pays off in fewer bugs, easier refactoring, and confident deployments.
Want help setting up quality tooling for your Laravel project? Get in touch - I've configured this stack for multiple teams.
Related Articles
Real-Time Laravel: Reverb vs Pusher vs Soketi
A practical comparison of real-time broadcasting options for Laravel applications, with implementation examples and performance insights.

10 Laravel Performance Tips I Learned the Hard Way
Practical performance optimization techniques for Laravel applications based on real-world experience scaling a production CRM.

Building MCP Servers for Claude: A Complete Guide
Learn how to extend Claude's capabilities by building custom MCP (Model Context Protocol) servers that provide tools, resources, and prompts.