Blog › ICP guides
PHP developer on retainer: Laravel architecture, PHP 8.3 features, static analysis, and testing on monthly retainer
August 21, 2026 · ~22 min read
A SaaS company running Laravel 9 had accumulated two years of technical debt at precisely the kind of steady rate that production systems tolerate until they suddenly don’t. The order listing endpoint — called 8,000 times per day by the mobile app — was executing 203 SQL queries for a page of 50 orders because the Blade views called $order->customer, $order->items, and $item->product through Eloquent’s lazy loading on each iteration of a foreach loop. Laravel Telescope was installed but nobody had read its query count panel. There was no static analysis. Job failures in the notification queue were silently caught by a bare catch (\Exception $e) {} block that had been introduced to “fix” a transient email timeout three months earlier and never removed.
A fractional PHP architect on monthly retainer started the first month with Laravel Debugbar and Telescope to make the invisible visible. Debugbar’s query panel confirmed 203 queries on the order listing page. The fix required replacing Order::all() with Order::with(['customer', 'items.product'])->paginate(50), adding withCount('items') to eliminate a secondary loop for count display, and removing a $order->shippingAddress lazy load inside the PDF generation method with a targeted load('shippingAddress') call at the method entry point. Query count dropped from 203 to 4, P99 response time dropped from 3.6 seconds to 190 milliseconds. The second month’s focus was PHP 8.2 readonly class refactoring for the 18 array-shaped DTOs that were being passed between service classes as plain associative arrays, making type checking impossible and PHPStan helpless. Each was converted to a readonly class with constructor promotion and typed properties. Month three introduced Laravel Queue with Horizon for the notification pipeline: the silent exception swallower was replaced with a proper failed() method, retry_after and backoff were tuned for the email provider’s rate limits, and ShouldBeUnique was implemented to prevent duplicate welcome emails on account creation race conditions.
PHP developers, Laravel architects, and PHP consultants on monthly retainer — fractional PHP engineers, Laravel performance advisors, and PHP static analysis specialists — do their highest-value work in the Laravel architecture, PHP language feature adoption, static analysis migration, and queue reliability design that produces the maintainable, observable backend the engineering lead can confidently evolve. This guide covers Laravel service container architecture in depth, PHP 8.3 language features, PHPStan and Psalm static analysis, Composer governance, and Laravel testing infrastructure with Pest and PHPUnit — and how to structure a PHP developer retainer that makes the hours behind each optimization visible.
Laravel architecture
Laravel’s service container, service providers, Eloquent ORM, queue system, and HTTP client form the foundational architecture that a PHP architect designs and maintains continuously. The binding strategy, provider lifecycle decisions, and Eloquent relationship design made early in a project propagate through every controller, job, and test in the application.
Service container: binding, resolution, and contextual binding
The Laravel service container is an IoC container that manages class dependencies through constructor injection. Understanding the difference between its binding methods is essential for correct scoping behavior, especially in queue workers and long-running processes.
<?php
// bind() — new instance on every resolution:
$this->app->bind(PaymentGatewayInterface::class, StripeGateway::class);
// singleton() — same instance for the lifetime of the container (request or process):
$this->app->singleton(CurrencyConverter::class, function ($app) {
return new CurrencyConverter(
$app->make(HttpClient::class),
config('services.fixer.api_key')
);
});
// scoped() — new instance per request/job lifecycle (Laravel 8.56+):
// Use for stateful services that should reset between queue jobs:
$this->app->scoped(OrderContext::class, OrderContext::class);
// instance() — bind an already-constructed object:
$this->app->instance(PdfRenderer::class, new WkHtmlToPdfRenderer('/usr/bin/wkhtmltopdf'));
// alias() — resolve by a short name:
$this->app->alias(PaymentGatewayInterface::class, 'payment');
// now app('payment') resolves to StripeGateway
// Contextual binding — give different implementations to different classes:
$this->app->when(OrderNotificationService::class)
->needs(MailerInterface::class)
->give(TransactionalMailer::class);
$this->app->when(MarketingEmailService::class)
->needs(MailerInterface::class)
->give(BulkMailer::class);
// Contextual binding with a closure for runtime configuration:
$this->app->when(ReportExporter::class)
->needs('$storageDriver')
->give(fn () => config('exports.storage_driver', 's3'));
// Tagged bindings — resolve all implementations of a tag:
$this->app->bind(CsvExporter::class);
$this->app->bind(PdfExporter::class);
$this->app->bind(XlsxExporter::class);
$this->app->tag(
[CsvExporter::class, PdfExporter::class, XlsxExporter::class],
'exporters'
);
// Resolve all tagged bindings:
$exporters = $this->app->tagged('exporters'); // returns iterable of all three
// Automatic resolution with constructor injection (no binding required):
// Laravel resolves type-hinted constructor parameters automatically:
class InvoiceService
{
public function __construct(
private readonly InvoiceRepository $repository,
private readonly PdfRenderer $renderer, // resolved automatically
private readonly MailerInterface $mailer // resolved via binding
) {}
}
Service providers: register vs. boot, deferred providers, and package assets
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Contracts\Support\DeferrableProvider;
// Deferred provider — only loaded when the service is actually resolved:
// Implement DeferrableProvider and return provides() to declare which bindings
// the provider registers. Container loads it on-demand, not on every request.
class ReportingServiceProvider extends ServiceProvider implements DeferrableProvider
{
// register() — bind services into the container.
// DO NOT call other services here — they may not be registered yet.
public function register(): void
{
$this->app->singleton(ReportGenerator::class, function ($app) {
return new ReportGenerator(
$app->make(QueryBuilder::class),
$app->make(TemplateEngine::class)
);
});
$this->app->bind(ReportFormatterInterface::class, HtmlReportFormatter::class);
}
// boot() — called after all providers are registered.
// Safe to resolve services, register event listeners, publish assets.
public function boot(): void
{
// Publish config for package consumers:
$this->publishes([
__DIR__ . '/../../config/reporting.php' => config_path('reporting.php'),
], 'reporting-config');
// Publish views:
$this->publishes([
__DIR__ . '/../../resources/views' => resource_path('views/vendor/reporting'),
], 'reporting-views');
// Load migrations without publishing (package-internal migrations):
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
// Register a macro on an existing class:
\Illuminate\Support\Collection::macro('toReport', function () {
return app(ReportGenerator::class)->fromCollection($this);
});
}
// provides() — tells the container which bindings this provider registers.
// Only called when DeferrableProvider is implemented.
public function provides(): array
{
return [ReportGenerator::class, ReportFormatterInterface::class];
}
}
Eloquent ORM: N+1 detection, eager loading, and relationship types
Eloquent’s lazy loading is the single most common source of hidden performance problems in Laravel applications. Every call to a navigation property inside a loop that was not pre-loaded with with() produces an additional query. A Laravel architect on retainer spends a significant portion of their hours auditing Eloquent usage with Telescope or Debugbar, identifying the N+1 patterns, and restructuring the data access layer to use eager loading correctly.
<?php
// Prevent lazy loading globally in development (raises MissingAttributeException):
// Add to AppServiceProvider::boot():
Model::preventLazyLoading(! app()->isProduction());
// with() — eager loading at query time:
$orders = Order::with([
'customer', // belongsTo
'items.product', // hasMany + belongsTo (nested eager load)
'items.discounts', // hasMany on items
])->paginate(50);
// withCount() — get relationship count without loading the full collection:
$orders = Order::withCount('items') // adds items_count attribute
->withCount(['items as pending_count' => function ($query) {
$query->where('status', 'pending');
}])
->get();
// load() — lazy eager loading on an already-retrieved collection:
// Use when you have an existing collection and discover mid-method that you need a relation:
$orders = Order::paginate(50); // already fetched
if ($needsCustomerData) {
$orders->load('customer'); // single IN query for all customer IDs
}
// loadMissing() — conditional eager loading (only loads if not already loaded):
$order->loadMissing(['customer', 'items']); // safe to call multiple times
// Filtering by relationship existence:
// has() — orders that have at least one item:
$ordersWithItems = Order::has('items')->get();
// has() with count threshold:
$largeOrders = Order::has('items', '>=', 5)->get();
// whereHas() — filter by relationship property:
$premiumOrders = Order::whereHas('customer', function ($query) {
$query->where('tier', 'premium');
})->get();
// Relationship types:
class Order extends Model
{
// belongsTo — Order belongs to one Customer:
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
// hasMany — Order has many OrderItems:
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
// hasManyThrough — Order has many Products through OrderItems:
public function products(): HasManyThrough
{
return $this->hasManyThrough(Product::class, OrderItem::class);
}
// belongsToMany — Order belongs to many Tags (with pivot table):
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'order_tags')
->withPivot('applied_at', 'applied_by') // include pivot columns
->withTimestamps();
}
// morphMany — Order has many polymorphic Comments:
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
// Custom pivot model with withPivot:
class OrderTag extends Pivot
{
protected $casts = [
'applied_at' => 'datetime',
];
public function appliedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'applied_by');
}
}
// Use in relationship definition:
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'order_tags')
->using(OrderTag::class)
->withPivot('applied_at', 'applied_by');
}
// Local scopes:
class Order extends Model
{
// Scope name must start with "scope" prefix:
public function scopePending(Builder $query): Builder
{
return $query->where('status', OrderStatus::Pending);
}
public function scopeForCustomer(Builder $query, string $customerId): Builder
{
return $query->where('customer_id', $customerId);
}
}
// Usage: Order::pending()->forCustomer('cust-001')->get();
// Global scope — applied to every query on the model:
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('tenant_id', auth()->user()?->tenant_id);
}
}
class Order extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope());
}
}
// Bypass a global scope for admin queries:
Order::withoutGlobalScope(TenantScope::class)->get();
// Accessor and mutator — new Attribute::make() style (Laravel 9+):
use Illuminate\Database\Eloquent\Casts\Attribute;
class Order extends Model
{
protected function formattedTotal(): Attribute
{
return Attribute::make(
get: fn ($value, $attributes) =>
number_format($attributes['total'] / 100, 2) . ' ' . $attributes['currency'],
);
}
// Casts — automatic type conversion:
protected $casts = [
'status' => OrderStatus::class, // backed enum cast
'metadata' => AsCollection::class, // JSON column as Collection
'secret_note' => 'encrypted:string', // transparent encryption/decryption
'shipped_at' => 'datetime', // Carbon instance
'tags_list' => AsEnumCollection::class . ':' . Tag::class, // collection of enums
];
}
Laravel Queue and Horizon
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendWelcomeEmail implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
// ShouldBeUnique — prevents duplicate jobs in the queue:
// uniqueId() defines what makes this job unique (by user):
public function uniqueId(): string
{
return $this->user->id;
}
// uniqueFor() — how long to hold the unique lock (seconds):
public int $uniqueFor = 3600;
// Job configuration:
public int $tries = 3;
public int $timeout = 60;
public int $backoff = 30; // seconds between retries (scalar or array [30, 60, 120])
public function __construct(private readonly User $user) {}
public function handle(MailerInterface $mailer): void
{
$mailer->send(new WelcomeEmail($this->user));
}
// failed() — called after all retries exhausted:
public function failed(\Throwable $exception): void
{
\Log::error('WelcomeEmail failed after retries', [
'user_id' => $this->user->id,
'exception' => $exception->getMessage(),
]);
// Notify the team, update user status, etc.:
event(new WelcomeEmailFailed($this->user->id));
}
}
// Dispatching:
SendWelcomeEmail::dispatch($user); // async
SendWelcomeEmail::dispatchSync($user); // synchronous (for testing)
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(5)); // deferred
// Job chaining — second job only runs if first succeeds:
Bus::chain([
new ProcessPayment($order),
new SendOrderConfirmation($order),
new UpdateInventory($order),
])->onQueue('critical')->dispatch();
// Batch dispatch — run many jobs in parallel, with then/catch/finally callbacks:
$batch = Bus::batch([
new GenerateInvoice($order1),
new GenerateInvoice($order2),
new GenerateInvoice($order3),
])
->then(function (Batch $batch) {
// All jobs completed successfully
event(new AllInvoicesGenerated($batch->id));
})
->catch(function (Batch $batch, \Throwable $e) {
// First batch job failure
\Log::error('Invoice batch failed', ['batch_id' => $batch->id]);
})
->finally(function (Batch $batch) {
// Runs when batch finishes (success or failure)
Cache::forget('invoice_batch_' . $batch->id);
})
->onQueue('invoices')
->dispatch();
// Horizon supervisor config (config/horizon.php):
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
'queue' => ['critical', 'default', 'emails'],
'balance' => 'auto', // auto-balances workers across queues by backlog
'minProcesses' => 1,
'tries' => 3,
],
],
]
Laravel HTTP client: pools, retries, and faking
<?php
use Illuminate\Support\Facades\Http;
// Http::pool() — concurrent requests (executes in parallel using cURL multi):
[$orders, $inventory, $shipping] = Http::pool(fn (Pool $pool) => [
$pool->as('orders')->get('https://api.example.com/orders'),
$pool->as('inventory')->get('https://api.example.com/inventory'),
$pool->as('shipping')->get('https://api.shipfaster.com/rates'),
]);
$orderData = $orders->json();
$inventoryData = $inventory->json();
$shippingData = $shipping->collect('rates'); // json() key as Collection
// retry() — automatic retry with exponential backoff:
$response = Http::retry(3, 100, function (\Exception $e, $request) {
// Only retry on connection errors or 5xx responses:
return $e instanceof \Illuminate\Http\Client\ConnectionException
|| ($e instanceof \Illuminate\Http\Client\RequestException
&& $e->response->serverError());
})->get('https://api.fragile-service.com/data');
// Authentication helpers:
Http::withToken($this->apiKey) // Bearer token header
->withBasicAuth($username, $password) // Basic auth
->withHeader('X-Tenant-Id', $tenantId) // custom header
->acceptJson()
->post('https://api.service.com/endpoint', $payload);
// Response methods:
$response->json(); // decoded JSON as array
$response->collect('data'); // JSON key as Illuminate\Support\Collection
$response->object(); // decoded JSON as stdClass
$response->status(); // HTTP status code (int)
$response->successful(); // true for 2xx
$response->clientError(); // true for 4xx
$response->serverError(); // true for 5xx
$response->throw(); // throw RequestException on 4xx/5xx
// Http::fake() in tests — prevents real HTTP calls:
Http::fake([
'api.stripe.com/*' => Http::response(['id' => 'ch_test'], 200),
'api.fragile.com/*' => Http::sequence()
->push(['error' => 'rate_limited'], 429)
->push(['data' => 'ok'], 200), // second call succeeds
'*' => Http::response([], 500), // fallback for unmatched URLs
]);
// Assert specific requests were made:
Http::assertSent(function ($request) {
return $request->url() === 'https://api.stripe.com/v1/charges'
&& $request->hasHeader('Authorization')
&& $request['amount'] === 5000;
});
PHP 8.3 language features
PHP has evolved substantially through versions 8.0 to 8.3, with each release adding features that reduce boilerplate, improve type safety, and unlock new programming patterns. A PHP architect on retainer typically spends hours per month on language feature adoption — migrating to backed enums, introducing readonly classes for DTOs, adopting PHP 8.3 typed class constants — work that produces no user-visible feature but eliminates entire categories of bugs.
PHP Fibers: cooperative concurrency without an event loop
PHP 8.1 introduced the Fiber class as a first-class primitive for cooperative concurrency. A Fiber is a lightweight coroutine that can be paused and resumed, allowing the main thread to interleave multiple logical tasks without preemptive threading. Fibers underpin the async implementation in Amphp v3 and ReactPHP event loops.
<?php
// Basic Fiber API:
$fiber = new Fiber(function (): void {
$value = Fiber::suspend('first yield'); // pauses fiber, returns 'first yield' to caller
echo "Fiber resumed with: {$value}\n"; // runs after resume() with a value
Fiber::suspend('second yield');
});
$first = $fiber->start(); // starts fiber, runs until first Fiber::suspend()
echo $first . "\n"; // 'first yield'
$second = $fiber->resume('hello'); // resumes fiber with 'hello' as return of suspend()
echo $second . "\n"; // 'second yield'
$fiber->resume(); // fiber runs to completion
// Amphp v3 — transparent async with fibers:
// amphp/amp v3 uses fibers so async functions look synchronous:
use Amp\Future;
use function Amp\async;
use function Amp\await;
// async() wraps a callable in a fiber and returns a Future:
$future1 = async(fn () => fetchFromDatabase('query-1'));
$future2 = async(fn () => fetchFromDatabase('query-2'));
$future3 = async(fn () => callExternalApi('/endpoint'));
// await() suspends the current fiber until the Future resolves:
[$result1, $result2, $result3] = Future\await([$future1, $future2, $future3]);
// All three run concurrently; total time = max(individual times), not sum
// ReactPHP with fibers (react/async):
use function React\Async\async;
use function React\Async\await;
use function React\Async\parallel;
$results = await(parallel([
async(fn () => $httpClient->get('https://api.service-a.com/data')),
async(fn () => $httpClient->get('https://api.service-b.com/data')),
async(fn () => $database->query('SELECT * FROM products LIMIT 100')),
]));
Match expressions, named arguments, and first-class callables
<?php
// match expression — no type coercion (strict equality), exhaustiveness checking,
// returns a value (unlike switch), no fall-through:
$label = match($order->status) {
OrderStatus::Pending => 'Awaiting payment',
OrderStatus::Paid => 'Processing',
OrderStatus::Shipped => 'On its way',
OrderStatus::Delivered => 'Delivered',
OrderStatus::Cancelled => 'Cancelled',
// No default — PHP throws UnhandledMatchError if status is not listed.
// With backed enums, PHPStan can verify exhaustiveness at static analysis time.
};
// match(true) — complex condition matching:
$discount = match(true) {
$customer->isPremium() && $order->total >= 1000 => 0.20,
$customer->isPremium() => 0.10,
$order->total >= 500 => 0.05,
default => 0.0,
};
// Named arguments — skip optional parameters, improve readability:
// Without named args: array_slice($array, 0, 5, true) — what does true mean?
$slice = array_slice(array: $products, offset: 0, length: 5, preserve_keys: true);
// Skip optional parameters entirely:
function createUser(
string $name,
string $email,
string $role = 'viewer',
bool $sendWelcome = true,
?string $timezone = null
): User { /* ... */ }
$user = createUser(name: 'Alice', email: 'alice@example.com', timezone: 'UTC');
// $role and $sendWelcome use their defaults; $timezone provided by name
// Named arguments in attributes:
#[Cache(ttl: 3600, tags: ['products', 'pricing'])]
public function getProductPricing(string $productId): array { /* ... */ }
// First-class callable syntax (PHP 8.1+):
// strlen(...) is a Closure — no anonymous function wrapper needed:
$lengths = array_map(strlen(...), $strings);
$trimmed = array_map(trim(...), $rawValues);
// Method references as first-class callables:
$encoder = $this->encoder->encode(...); // Closure wrapping $this->encoder->encode()
$encoded = array_map($encoder, $payloads);
// Static method references:
$parsed = array_map(Money::fromString(...), $rawAmounts);
// Use with usort:
usort($products, fn ($a, $b) => strcmp($a->name, $b->name));
// Or with first-class callable:
usort($products, strcmp(...)); // only when the signature matches exactly
Readonly classes, backed enums, and intersection types
<?php
// Backed enum — string-backed with from() and tryFrom():
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
// Enum methods:
public function label(): string
{
return match($this) {
self::Pending => 'Awaiting Payment',
self::Paid => 'Paid — Processing',
self::Shipped => 'Shipped',
self::Delivered => 'Delivered',
self::Cancelled => 'Cancelled',
};
}
public function isFinal(): bool
{
return in_array($this, [self::Delivered, self::Cancelled]);
}
// List all cases as a select-box array:
public static function options(): array
{
return array_column(self::cases(), 'value', 'name');
}
}
// Usage:
$status = OrderStatus::from('paid'); // OrderStatus::Paid (throws ValueError on bad value)
$status = OrderStatus::tryFrom('unknown'); // null (safe, no exception)
$label = OrderStatus::Paid->label(); // 'Paid — Processing'
$value = OrderStatus::Shipped->value; // 'shipped'
// Eloquent cast — automatic enum casting:
class Order extends Model
{
protected $casts = [
'status' => OrderStatus::class, // DB string column cast to enum automatically
];
}
// PHP 8.2 readonly class — all properties implicitly readonly:
readonly class CreateOrderDto
{
public function __construct(
public string $customerId,
public string $currency,
/** @var non-empty-list<OrderItemDto> */
public array $items,
public ?string $couponCode = null,
public ?string $shippingAddressId = null,
) {}
// Readonly classes cannot declare non-readonly properties.
// To "modify" a value, return a new instance using clone with spread:
public function withCoupon(string $code): static
{
return new static(
customerId: $this->customerId,
currency: $this->currency,
items: $this->items,
couponCode: $code,
shippingAddressId: $this->shippingAddressId,
);
}
}
// PHP 8.3 typed class constants:
interface ApiVersioned
{
const string API_VERSION = 'v2';
const int MAX_PAGE_SIZE = 200;
}
class StripeGateway implements ApiVersioned
{
const string API_VERSION = 'v2'; // must match interface constant type
const int MAX_PAGE_SIZE = 100; // can have different value
const string BASE_URL = 'https://api.stripe.com';
}
// Intersection types — value must satisfy both interfaces simultaneously:
function processExportable(Iterator&Countable $items): void
{
$total = count($items); // Countable: can count
foreach ($items as $item) { /* ... */ } // Iterator: can iterate
}
// Intersection with union:
function transform(Iterator&Countable|array $data): array
{
if (is_array($data)) {
return $data;
}
return iterator_to_array($data);
}
Static analysis with PHPStan and Psalm
PHPStan and Psalm are the two dominant static analysis tools for PHP. A PHP architect on retainer typically spends 20 to 50 hours migrating a mature Laravel codebase from no static analysis to PHPStan level 9 — work that produces no user-visible feature but eliminates entire categories of runtime type errors, undefined variable bugs, and incorrect return type assumptions that would otherwise surface as production incidents.
PHPStan levels, Larastan, and custom rules
# phpstan.neon — PHPStan configuration file
parameters:
# Level 0: basic checks (undefined variables, unknown classes, wrong argument counts)
# Level 1: possibly undefined variables, unknown magic methods
# Level 2: unknown methods on mixed, $this on non-object
# Level 3: return types, types in property assignments
# Level 4: dead code, always-true conditions
# Level 5: checking types in method calls and property access
# Level 6: report missing typehints as errors
# Level 7: report partially wrong union types
# Level 8: nullable types strict checks
# Level 9: mixed type strictly disallowed (most strict — every value must be typed)
level: 9
paths:
- app
- database/factories
- database/seeders
excludePaths:
- app/Http/Middleware/TrustProxies.php # framework boilerplate
# Ignore specific errors from third-party code or legacy areas:
ignoreErrors:
# Suppress a specific error pattern:
- message: '#Call to an undefined method Illuminate\\Database\\Eloquent\\Builder#'
path: app/Models/Order.php
count: 2
# reportUnmatchedIgnoredErrors: true means PHPStan errors if an ignore
# no longer matches (prevents stale ignores accumulating):
reportUnmatchedIgnoredErrors: true
# Treat collections as generic (requires Larastan):
checkGenericClassInNonGenericObjectType: true
includes:
- vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
<?php
// Generate a baseline to freeze current errors and prevent regression:
// ./vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon
// Then include in phpstan.neon:
// includes:
// - phpstan-baseline.neon
// New code added after baseline generation must pass at level 9.
// Periodically reduce the baseline by fixing catalogued errors.
// Custom PHPStan rule — enforce a project convention:
namespace App\PHPStan;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<Node\Expr\New_>
*/
class NoBareExceptionRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\New_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (! $node->class instanceof Node\Name) {
return [];
}
if ($node->class->toString() === 'Exception') {
return [
RuleErrorBuilder::message(
'Do not throw bare Exception. Use a domain-specific exception class.'
)->build(),
];
}
return [];
}
}
// Service class satisfying PHPStan level 9 — fully typed, no mixed:
namespace App\Services;
use App\DTOs\CreateOrderDto;
use App\DTOs\OrderDto;
use App\Enums\OrderStatus;
use App\Exceptions\CustomerNotFoundException;
use App\Repositories\Contracts\CustomerRepositoryInterface;
use App\Repositories\Contracts\OrderRepositoryInterface;
final class OrderService
{
public function __construct(
private readonly OrderRepositoryInterface $orders,
private readonly CustomerRepositoryInterface $customers,
) {}
/**
* @throws CustomerNotFoundException
*/
public function create(CreateOrderDto $dto): OrderDto
{
$customer = $this->customers->findOrFail($dto->customerId);
$order = new \App\Models\Order();
$order->customer_id = $customer->id;
$order->currency = $dto->currency;
$order->status = OrderStatus::Pending;
$order->save();
foreach ($dto->items as $item) {
$order->items()->create([
'product_id' => $item->productId,
'quantity' => $item->quantity,
'unit_price' => $item->unitPrice,
]);
}
return OrderDto::fromModel($order->load('items'));
}
}
Psalm strict mode and generics
<?php
/**
* Psalm template generics — type-safe collection class:
*
* @template T of \Illuminate\Database\Eloquent\Model
*/
class TypedCollection
{
/** @var list<T> */
private array $items = [];
/**
* @param T $item
*/
public function add(mixed $item): void
{
$this->items[] = $item;
}
/**
* @return list<T>
*/
public function all(): array
{
return $this->items;
}
/**
* @template R
* @param callable(T): R $transform
* @return list<R>
*/
public function map(callable $transform): array
{
return array_map($transform, $this->items);
}
}
// @psalm-immutable — Psalm verifies no property mutations after construction:
/** @psalm-immutable */
final class Money
{
public function __construct(
public readonly int $amount, // stored as cents to avoid float precision issues
public readonly string $currency
) {}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException(
"Cannot add {$this->currency} to {$other->currency}"
);
}
return new self($this->amount + $other->amount, $this->currency);
}
/** @psalm-pure */
public function format(): string
{
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
}
// @psalm-suppress for third-party library incompatibilities:
/** @psalm-suppress MixedReturnStatement */
public function getConfigValue(string $key): mixed
{
return config($key); // Laravel's config() returns mixed; suppress until annotated
}
// Update Psalm baseline (analogous to PHPStan --generate-baseline):
// ./vendor/bin/psalm --update-baseline
Composer and dependency governance
Composer governance is a category of retainer work that prevents accumulation of vulnerable dependencies, conflicting version constraints, and deployment environment mismatches. A PHP architect on retainer performs monthly security audits, manages the composer.lock update cadence, and structures the composer.json for reproducible builds.
Version constraints, conflict resolution, and platform requirements
{
"name": "acme/saas-platform",
"description": "ACME SaaS API",
"type": "project",
"license": "proprietary",
"require": {
"php": "^8.3",
"ext-bcmath": "*",
"ext-redis": "^5.0",
"laravel/framework": "^11.0",
"laravel/horizon": "^5.26",
"laravel/telescope": "^5.2",
"larastan/larastan": "^2.9",
"league/flysystem-aws-s3-v3": "^3.28"
},
"require-dev": {
"pestphp/pest": "^2.36",
"pestphp/pest-plugin-laravel": "^2.4",
"phpstan/phpstan": "^1.12",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "^11.4"
},
"conflict": {
"barryvdh/laravel-debugbar": "<3.13"
},
"replace": {
"acme/legacy-mailer": "*"
},
"scripts": {
"post-install-cmd": [
"@php artisan key:generate --ansi --no-interaction",
"@php artisan storage:link --no-interaction"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"Illuminate\\Foundation\\ComposerScripts::postUpdate"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"analyse": "phpstan analyse --memory-limit=512M",
"test": "pest --parallel",
"audit": "composer audit --no-dev"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "8.3.0",
"ext-redis": "5.0.0"
},
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": false
}
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"repositories": [
{
"type": "composer",
"url": "https://repo.packagist.com/acme-org/",
"only": ["acme/*"]
}
]
}
# Monthly Composer security audit (integrate into CI, fail on vulnerabilities):
composer audit --no-dev
# Exit code 1 if any vulnerable package found.
# In GitHub Actions:
# - run: composer audit --no-dev
# name: Security audit
# Optimize autoloader for production (classmap pre-generated, no file scanning):
composer install --no-dev --optimize-autoloader --no-scripts --prefer-dist
# Check platform requirements without actual deployment:
composer check-platform-reqs
# Diagnose dependency conflicts:
composer why-not laravel/framework 12.0 # what prevents upgrading to 12.0
composer why league/flysystem-aws-s3-v3 # why is this package installed
Testing: PHPUnit, Pest, and Laravel testing infrastructure
Laravel testing infrastructure design is a category of retainer work that produces no user-visible artifact but dramatically reduces future bug rates and enables confident refactoring. A PHP architect on retainer builds the test infrastructure once — the Pest dataset conventions, the HTTP and queue fake patterns, the database seeding strategy for tests — and maintains it continuously as the application grows.
Database traits, HTTP fakes, and queue, event, and notification fakes
<?php
namespace Tests\Feature;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
// RefreshDatabase: wraps each test in a transaction and migrates once per test run.
// Use for feature tests that need clean database state.
// DatabaseTransactions: wraps each test in a transaction and rolls back — does NOT re-migrate.
// Faster, but cannot test transactions in application code.
class OrderApiTest extends TestCase
{
use RefreshDatabase;
public function test_authenticated_user_can_create_order(): void
{
$user = User::factory()->create();
Queue::fake(); // intercept all dispatched jobs — no worker needed
Event::fake(); // intercept all fired events
Notification::fake(); // intercept all sent notifications
Http::fake([
'api.inventory.com/*' => Http::response(['available' => true], 200),
]);
$response = $this->actingAs($user)
->postJson('/api/v1/orders', [
'currency' => 'USD',
'items' => [
['product_id' => 'prod-001', 'quantity' => 2],
],
]);
$response->assertCreated()
->assertJsonPath('status', 'pending')
->assertJsonPath('currency', 'USD');
// Assert job was dispatched with correct payload:
Queue::assertPushed(SendOrderConfirmation::class, function ($job) use ($response) {
return $job->orderId === $response->json('id');
});
// Assert event was fired:
Event::assertDispatched(OrderCreated::class, function ($event) use ($response) {
return $event->orderId === $response->json('id');
});
// Assert notification was sent to the user:
Notification::assertSentTo($user, OrderConfirmationNotification::class);
// Assert HTTP call was made to inventory service:
Http::assertSent(fn ($request) =>
str_contains($request->url(), 'api.inventory.com')
);
}
}
Pest PHP: datasets, describe blocks, and snapshot testing
<?php
// tests/Feature/OrderTest.php — Pest syntax
use App\Enums\OrderStatus;
use App\Models\Order;
use App\Models\User;
// beforeEach() — runs before every test in this file:
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
// describe() — group related tests (nested describe supported):
describe('Order creation', function () {
it('creates an order with valid payload', function () {
Queue::fake();
Http::fake(['api.inventory.com/*' => Http::response(['available' => true])]);
$response = $this->postJson('/api/v1/orders', [
'currency' => 'USD',
'items' => [['product_id' => 'prod-001', 'quantity' => 1]],
]);
expect($response->status())->toBe(201)
->and($response->json('status'))->toBe('pending')
->and($response->json('currency'))->toBe('USD');
Queue::assertPushed(SendOrderConfirmation::class);
});
// dataset() — parameterized tests for validation scenarios:
it('rejects invalid order payloads', function (array $payload, string $field, string $message) {
$response = $this->postJson('/api/v1/orders', $payload);
expect($response->status())->toBe(422)
->and($response->json("errors.{$field}"))->toContain($message);
})->with([
'missing currency' => [['items' => [['product_id' => 'p1', 'quantity' => 1]]], 'currency', 'required'],
'empty items' => [['currency' => 'USD', 'items' => []], 'items', 'required'],
'invalid currency code' => [['currency' => 'INVALID', 'items' => [['product_id' => 'p1', 'quantity' => 1]]], 'currency', 'invalid'],
'zero quantity' => [['currency' => 'USD', 'items' => [['product_id' => 'p1', 'quantity' => 0]]], 'items.0.quantity', 'minimum'],
]);
});
describe('Order status transitions', function () {
it('transitions through valid statuses', function (OrderStatus $from, OrderStatus $to, bool $allowed) {
$order = Order::factory()->create(['status' => $from]);
$result = $order->transitionTo($to);
expect($result)->toBe($allowed);
})->with([
'pending to paid' => [OrderStatus::Pending, OrderStatus::Paid, true],
'paid to shipped' => [OrderStatus::Paid, OrderStatus::Shipped, true],
'pending to shipped' => [OrderStatus::Pending, OrderStatus::Shipped, false],
'delivered to cancelled' => [OrderStatus::Delivered, OrderStatus::Cancelled, false],
]);
});
// Snapshot testing — assert complex JSON structure matches a stored snapshot:
it('returns correct order structure', function () {
$order = Order::factory()
->has(OrderItem::factory()->count(3), 'items')
->create(['status' => OrderStatus::Paid]);
$response = $this->getJson("/api/v1/orders/{$order->id}");
expect($response->json())->toMatchSnapshot();
// First run: saves snapshot to tests/__snapshots__/OrderTest__returns_correct_order_structure_1.json
// Subsequent runs: asserts JSON matches saved snapshot exactly
});
Laravel Dusk browser testing
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
class CheckoutFlowTest extends DuskTestCase
{
public function test_guest_can_complete_checkout(): void
{
$this->browse(function (Browser $browser) {
$browser
->visit('/products/acme-widget')
->assertSee('ACME Widget')
->press('Add to Cart')
->waitFor('.cart-count', 5) // wait up to 5 seconds for element
->assertSeeIn('.cart-count', '1')
->clickLink('Checkout')
->waitForLocation('/checkout') // wait for URL to change
->type('input[name=email]', 'test@example.com')
->type('input[name=name]', 'Test User')
->select('select[name=country]', 'US')
->type('input[name=postal]', '90210')
// Stripe Elements iframe requires withinFrame():
->withinFrame('.StripeElement iframe', function ($stripe) {
$stripe->type('input[name=cardnumber]', '4242424242424242')
->type('input[name=exp-date]', '12/28')
->type('input[name=cvc]', '123');
})
->press('Complete Order')
->waitForLocation('/order/confirmation', 15) // wait up to 15 seconds
->assertSee('Order confirmed')
->assertUrlContains('/order/confirmation')
->screenshot('checkout-complete'); // saved on failure automatically too
});
}
public function test_invalid_card_shows_error(): void
{
$this->browse(function (Browser $browser) {
$browser
->visit('/checkout')
->withinFrame('.StripeElement iframe', function ($stripe) {
$stripe->type('input[name=cardnumber]', '4000000000000002'); // decline card
})
->press('Complete Order')
->waitUntilMissing('.spinner')
->assertSee('Your card was declined');
});
}
}
Logging PHP and Laravel retainer hours so clients understand the work
PHP retainer work is invisible in the same way that all platform engineering work is invisible: an Eloquent N+1 optimization that reduces 203 database queries to 4 per request produces no new endpoint, no new feature, and no visible change in the business logic — only a faster API that handles four times the concurrent users on the same infrastructure. A PHPStan level 9 migration that resolves 847 type errors produces a codebase that is safer but looks identical from the product manager’s perspective. A Laravel Queue reliability improvement that adds a proper failed() method and ShouldBeUnique implementation produces no change the stakeholder can observe until the day a duplicate welcome email incident does not happen.
The work log entry is what connects the invisible PHP platform work to its concrete business outcome. A good entry captures: the advisory category (Eloquent N+1 optimization, PHPStan level migration, Laravel Queue reliability, readonly DTO migration, service container design, Composer security audit, Pest test infrastructure), the specific controller, service, or module being worked on, the task performed, the Telescope or Debugbar finding, the implementation approach, and the measured outcome.
HourTab turns this structured work log into a public retainer URL that the client can bookmark — a live view of hours logged, progress against the monthly allocation, and the work summaries behind each line. When the client asks “what has our Laravel architect been doing this month?”, the HourTab URL answers with the Telescope query count analysis, the N+1 reduction, the PHPStan level reached, and the job failure rate improvement, without requiring a status call or a weekly report document.
Retainer structure for PHP developer engagements
A PHP developer retainer typically covers four functional areas: feature development (new Laravel controllers and routes, new Eloquent models and relationships, new queue jobs and listeners), query optimization advisory (Telescope and Debugbar analysis, eager loading restructuring, query scope design, indexed column identification), PHP language feature adoption (readonly class migration, backed enum introduction, PHP 8.3 typed constants, PHPStan baseline establishment and progression), and testing infrastructure (Pest dataset conventions, HTTP and queue fake patterns, Laravel Dusk end-to-end test setup). Each area should have its own hour allocation in the retainer agreement.
Entry-level PHP developers typically bill at $65–$120/hr, with monthly retainers running 10 to 20 hours for feature development and code review. Mid-level PHP engineers bill at $110–$195/hr, with monthly retainers running 15 to 30 hours for N+1 optimization, static analysis migration, and queue reliability work. Senior PHP architects bill at $170–$315/hr, with monthly retainers running 20 to 40 hours for Laravel internals advisory, Fiber-based concurrency design, and Psalm generics work. PHP and Laravel consulting firms typically bill at $140–$250/hr.
Monthly retainer amounts for PHP developer advisory and Laravel architecture consulting typically range from $3,500 to $7,500 per month for backend architecture advisory retainers (15 to 30 hours per month at mid-to-senior rates), increasing to $12,000 to $20,000 per month for full-stack Laravel architecture consulting engagements (30 to 60 hours per month) covering Eloquent optimization, PHPStan migration through level 9, async queue design with Horizon, Composer governance, and Pest test infrastructure build-out.
The retainer pays for itself when it prevents a single production incident: a codebase that grows for two years without Eloquent review typically has 20 to 40 N+1 query patterns that collectively drive database CPU to saturation under moderate load — the kind of incident that requires emergency database scaling, followed by weeks of query optimization under pressure. Monthly retainer advisory prevents that accumulation and keeps each optimization sprint sized to hours rather than emergency days.
HourTab is a public retainer dashboard for freelance PHP developers, Laravel architects, and PHP consulting firms. Upload your time-tracker CSV and get a shareable URL your client can bookmark — a live view of hours logged, remaining allocation, and work log summaries. No client login, no portal. Try it free with one active retainer.