This guide outlines the coding standards, patterns, and best practices for the Anchor framework. Following these practices ensures code quality, maintainability, and consistency across the codebase.
The framework uses constructor injection as the primary dependency injection pattern. This approach improves testability, reduces coupling, and makes dependencies explicit.
<?php
namespace MyPackage\Services;
use Core\Services\ConfigServiceInterface;
use Helpers\File\Adapters\Interfaces\FileMetaInterface;
class MyService
{
public function __construct(
private ConfigServiceInterface $config,
private FileMetaInterface $fileMeta
) {}
public function doSomething(): void
{
$path = $this->config->get('mypackage.path');
if ($this->fileMeta->exists($path)) {
// Process file
}
}
}Why?
- Dependencies are explicit and visible
- Easy to mock in tests
- Impossible to create instance without required dependencies
The Anchor framework provides two primary ways to access system services: Constructor Injection and Global Helper Functions. Choosing the right one depends on your context and requirements.
For complex services, package development, or components that require deep unit testing, constructor injection is the gold standard.
// Highly testable, explicit dependencies
class ProfileService
{
public function __construct(
private ConfigServiceInterface $config,
private SessionInterface $session
) {}
public function getTheme(): string
{
return $this->session->get('theme') ?? $this->config->get('app.default_theme');
}
}Global helpers like config(), session(), request(), and response() are designed for rapid development. They are perfectly acceptable in Controllers, Views, and non-critical application logic where brevity improves readability.
// Clean, concise, and fast to write
class ProfileController
{
public function update()
{
$theme = request()->get('theme');
session()->set('theme', $theme);
return redirect()->back();
}
}The Golden Rule: Use Constructor Injection if you need to mock the dependency in a unit test. Use Global Helpers if the convenience outweighs the need for strict isolation.
Use when you need the same instance throughout the application lifecycle:
<?php
namespace MyPackage\Providers;
use Core\Services\ServiceProvider;
use MyPackage\Services\CacheService;
class MyServiceProvider extends ServiceProvider
{
public function register(): void
{
// Same instance returned every time
$this->container->singleton(CacheService::class);
}
}Use cases: Database connections, caching, configuration, logging
Use when you need a fresh instance every time:
<?php
public function register(): void
{
// New instance on every resolve
$this->container->bind(PaymentProcessor::class);
}Use cases: Request handlers, form validators, DTOs
For business logic that is reusable across Controllers, Commands, or Jobs, use the Single Action Pattern. This keeps controllers slim and logic testable in isolation.
Every Action must extend App\Core\BaseAction and implement the execute() method.
<?php
namespace App\Account\Actions;
use App\Core\BaseAction;
use App\Models\User;
class UpdateProfileAction extends BaseAction
{
public function execute(mixed $data): User
{
$user = $data['user'];
$user->update($data['attributes']);
return $user;
}
}Benefits:
- Reusability: Use the same logic in a web controller and a CLI command.
- Isolation: Test the action independently of the HTTP request.
- Clarity: Each file has one job.
To prevent complex logic from leaking into your views, wrap your models in a ViewModel. These should be readonly classes that handle data transformation.
<?php
namespace App\Account\Views\Models;
use App\Models\User;
use Helpers\String\Str;
readonly class UserViewModel
{
public function __construct(private User $user) {}
public function getShortName(): string
{
return Str::shortenWithInitials($this->user->name);
}
public function getStatusColor(): string
{
return $this->user->isActive() ? 'green' : 'red';
}
}Rule: Views should only interact with ViewModels, never raw Models for complex transformations.
Anchor's modular nature requires a disciplined approach to how packages interact.
When a package needs to interact with another, always prefer Soft Integration.
- Hard Integration: Direct imports or dependencies that cause the application to crash if the other package is missing. (e.g.,
use OtherPackage\Models\Record;) - Soft Integration: Checking for existence and using high-level abstractions.
// ✅ Soft Integration Pattern
use OtherPackage\Other;
public function process(int $id)
{
if (class_exists(Other::class)) {
// Use facade or service, not record directly
Other::dispatch($id);
}
}Integrations should be non-blocking. If an optional package fails, the primary package should continue its work. Always wrap optional cross-package calls in try-catch blocks or conditional checks.
try {
Slot::book($scheduleId, $user, $period);
} catch (Throwable $e) {
Log::warning("Optional Slot booking failed: " . $e->getMessage());
// Proceed anyway - the core 1-on-1 meeting is still valid
}To maintain a strict Separation of Concerns, you should never modify core models (like User) to add package-specific logic. Instead, use Macros in your package's Service Provider.
use App\Models\User;
use Metric\Models\Manager;
public function register(): void
{
// Inject relationship into User model at runtime
User::macro('manager', function() {
return $this->belongsTo(Manager::class, 'manager_id');
});
}This ensures that the Metric package remains self-contained, and the User model remains clean and agnostic of the packages installed.
If you are building a new system component that others might want to extend, use the System\Helpers\Macroable trait.
use System\Helpers\Macroable;
class ViewEngine
{
use Macroable;
}
// Elsewhere (e.g., in a Service Provider)
ViewEngine::macro('customHelper', function() { ... });While native PHP functions like file_exists() or mkdir() work, the framework encourages using its abstraction layers (like the FileSystem helper) for consistency and to simplify cross-platform path handling.
| Interface | Purpose | Common Methods |
|---|---|---|
PathResolverInterface |
Resolve framework paths | basePath(), appPath(), storagePath(), systemPath(), configPath(), publicPath() |
FileMetaInterface |
File metadata operations | exists(), isDir(), isFile(), isReadable(), size(), chmod(), permissions() |
FileReadWriteInterface |
Read/write operations | get(), put(), replace(), prepend(), append() |
FileManipulationInterface |
File/directory manipulation | copy(), move(), delete(), mkdir() |
<?php
namespace Package;
use Helpers\File\Adapters\Interfaces\FileMetaInterface;
use Helpers\File\Adapters\Interfaces\FileManipulationInterface;
use Helpers\File\Adapters\Interfaces\PathResolverInterface;
use RuntimeException;
class PackageManager
{
public function __construct(
private PathResolverInterface $paths,
private FileMetaInterface $fileMeta,
private FileManipulationInterface $fileManipulation
) {}
public function publishConfig(string $packagePath): int
{
$source = $packagePath . DIRECTORY_SEPARATOR . 'Config';
// Use injected interface, not is_dir()
if (!$this->fileMeta->isDir($source)) {
return 0;
}
$dest = $this->paths->appPath('Config');
// Use abstraction for copying
return $this->copyDirectoryContents($source, $dest);
}
private function copyDirectoryContents(string $source, string $dest): int
{
// Validate with abstraction
if (!$this->fileMeta->isDir($source)) {
throw new RuntimeException("Source directory does not exist: {$source}");
}
// Create directory with abstraction
if (!$this->fileMeta->isDir($dest)) {
$this->fileManipulation->mkdir($dest, 0755, true);
}
// FilesystemIterator is acceptable for iteration
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
$count = 0;
foreach ($items as $item) {
$target = $dest . DIRECTORY_SEPARATOR . $item->getBasename();
if ($item->isDir()) {
$count += $this->copyDirectoryContents($item->getPathname(), $target);
} else {
// Use abstraction for file operations
if (!$this->fileManipulation->copy($item->getPathname(), $target)) {
throw new RuntimeException("Failed to copy: {$item->getPathname()}");
}
$count++;
}
}
return $count;
}
}While native PHP functions like is_dir() and copy() are familiar, using the framework's abstractions (like FileSystem) provides several advantages:
- Testability: Easy to mock in unit tests.
- Cross-platform Consistency: Handles path separators and directory permissions automatically.
- Advanced Features: Methods like
FileSystem::replace()provide atomic writes that native PHP doesn't offer out of the box.
// ✅ Recommended: Use FileSystem helper for I/O operations
if (!FileSystem::isDir($source)) {
return 0;
}
// ✅ Acceptable: Use native functions for simple, non-I/O operations
$extension = pathinfo($filename, PATHINFO_EXTENSION);Why use abstractions for I/O?
- Mocking: You can test how your code handles a full disk or an unreadable file without actually touching the disk.
- Portability: Your code will work correctly on Windows, Linux, and Cloud Storage if you use the abstraction layer.
It's acceptable to use SPL classes like FilesystemIterator, DirectoryIterator, and SplFileInfo for iteration since they provide better performance and features than scandir():
<?php
// ✅ Acceptable - SPL is fine for iteration
$items = new \FilesystemIterator($dir, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
if ($item->isDir()) {
// Process directory
}
}Throw exceptions for exceptional conditions that prevent normal execution:
<?php
public function resolvePackagePath(string $package, bool $isSystem): string
{
$base = $isSystem
? $this->paths->systemPath($package)
: $this->paths->basePath("packages/{$package}");
// Throw exception - caller cannot proceed without valid path
if (!$this->fileMeta->isDir($base)) {
throw new RuntimeException("Package not found at: {$base}");
}
return $base;
}Throw exceptions when:
- Required resource doesn't exist (file, directory, database record)
- Invalid input that cannot be recovered
- System is in an invalid state
- External service fails
Return error codes/flags for expected alternative outcomes:
<?php
public function publishConfig(string $packagePath): int
{
$source = $packagePath . DIRECTORY_SEPARATOR . 'Config';
// Return 0 - valid outcome, just nothing to do
if (!$this->fileMeta->isDir($source)) {
return 0;
}
return $this->copyDirectoryContents($source, $dest);
}Return error indicators when:
- Optional operation has nothing to do (no files to copy)
- Multiple valid outcomes exist (success, skip, partial)
- Caller needs to make decisions based on the result
For domain-specific errors, create custom exceptions:
<?php
namespace Package\Exceptions;
use RuntimeException;
class PackageNotFoundException extends RuntimeException
{
public static function forPackage(string $name, string $path): self
{
return new self("Package '{$name}' not found at: {$path}");
}
}
class PackageInstallationException extends RuntimeException
{
public static function copyFailed(string $file, string $reason): self
{
return new self("Failed to copy '{$file}': {$reason}");
}
}Usage:
<?php
if (!$this->fileMeta->isDir($base)) {
throw PackageNotFoundException::forPackage($package, $base);
}PHPStan is a static analysis tool that catches type errors without running your code. The framework is configured to run at level 5, which provides a good balance between strictness and practicality.
# Analyze entire codebase
php vendor/bin/phpstan analyse
# Analyze specific directory
php vendor/bin/phpstan analyse System/Package
# Analyze specific file
php vendor/bin/phpstan analyse System/Package/PackageManager.phpLevel 0 - Basic checks (undefined variables, unknown properties)
Level 1 - Unknown classes
Level 2 - Unknown methods called on $this
Level 3 - Return type checks
Level 4 - Check for dead code
Level 5 ⭐ - Framework default - Check argument types
Level 6 - Report missing typehints
Level 7 - Report partial union types
Level 8 - Check for nullable types
Level 9 - Maximum strictness - mixed types not allowed
Current Configuration (phpstan.neon):
parameters:
level: 5
bootstrapFiles:
- System/Core/init.php
paths:
- App
- System
- index.php
excludePaths:
- %currentWorkingDirectory%/App/storage/*<?php
// ❌ Bad
public function process(): void
{
if ($condition) {
$result = $this->calculate();
}
echo $result; // Error: $result might not be defined
}
// ✅ Good
public function process(): void
{
$result = null; // Initialize
if ($condition) {
$result = $this->calculate();
}
if ($result !== null) {
echo $result;
}
}<?php
// ❌ Bad
public function getConfigPath(): ?string
{
return $this->config->get('path'); // Might return null
}
public function process(): void
{
$path = $this->getConfigPath();
$this->fileMeta->exists($path); // Error: expects string, got string|null
}
// ✅ Good - Option 1: Guard clause
public function process(): void
{
$path = $this->getConfigPath();
if ($path === null) {
throw new RuntimeException('Config path not set');
}
$this->fileMeta->exists($path); // Now PHPStan knows it's string
}
// ✅ Good - Option 2: Null coalescing
public function process(): void
{
$path = $this->getConfigPath() ?? '/default/path';
$this->fileMeta->exists($path);
}<?php
// ❌ Bad - Type mismatch
public function multiply(int $factor): int
{
return $this->multiply(RoundingMode::CEILING); // CEILING is a string
}
// ✅ Good - Use proper types
class RoundingMode
{
public const CEILING = 1;
public const FLOOR = 2;
public const HALF_UP = 3;
}
public function multiply(int $roundingMode): int
{
return $this->multiply(RoundingMode::CEILING);
}Always use type hints for:
<?php
// ✅ Method parameters and return types
public function parse(string $input): array
{
return json_decode($input, true);
}
// ✅ Class properties (PHP 7.4+)
class MyClass
{
private ConfigServiceInterface $config;
private ?string $cache = null; // Nullable type
/** @var array<string, mixed> */
private array $options = []; // PHPDoc for complex types
}
// ✅ Closure parameters
$callback = function (User $user): void {
echo $user->getName();
};Architecture tests ensure your codebase follows structural rules and conventions. They catch violations like improper dependencies, naming violations, or layer breaches.
Pest supports architecture testing out of the box. Create a dedicated file:
File: tests/System/Architecture/ArchitectureTest.php
<?php
declare(strict_types=1);
describe('Architecture Rules', function () {
// Rule: No native file functions in Package namespace
arch('Package namespace does not use native file functions')
->expect('Package')
->not->toUse([
'file_exists',
'is_dir',
'is_file',
'scandir',
'copy',
'unlink',
'mkdir',
'rmdir',
'file_get_contents',
'file_put_contents'
]);
// Rule: Package classes use proper abstractions
arch('Package classes only depend on approved interfaces')
->expect('Package\\PackageManager')
->toOnlyUse([
'Core\\Services\\ConfigServiceInterface',
'Helpers\\File\\Adapters\\Interfaces\\*',
'Database\\*',
'RuntimeException',
'Throwable',
// PHP native classes are OK
'FilesystemIterator',
'DirectoryIterator'
]);
// Rule: Service providers follow naming convention
arch('Service providers follow naming convention')
->expect('*\\Providers\\*')
->toHaveSuffix('ServiceProvider')
->toExtend('Core\\Services\\ServiceProvider');
// Rule: Commands follow naming convention
arch('Console commands follow naming convention')
->expect('*\\Commands\\*')
->toHaveSuffix('Command')
->toExtend('Symfony\\Component\\Console\\Command\\Command');
// Rule: Middleware follows naming convention
arch('Middleware follows naming convention')
->expect('*\\Middleware\\*')
->toHaveSuffix('Middleware');
// Rule: Strict types declared everywhere
arch('All PHP files declare strict types')
->expect('App')
->toUseStrictTypes();
arch('System uses strict types')
->expect('System')
->toUseStrictTypes();
// Rule: No debug functions in production code
arch('Production code does not use debug functions')
->expect('App')
->not->toUse(['dd', 'dump', 'var_dump', 'print_r']);
arch('System code does not use debug functions')
->expect('System')
->not->toUse(['dd', 'dump', 'var_dump', 'print_r']);
});# Run all architecture tests
php vendor/bin/pest tests/System/Architecture/ArchitectureTest.php
# Run specific test
php vendor/bin/pest --filter="Package namespace does not use native file functions"<?php
// Controllers should only use services, not models directly
arch('Controllers use services, not models')
->expect('App\\Controllers')
->not->toUse('App\\Models');
// Models should not use controllers or middleware
arch('Models are independent')
->expect('App\\Models')
->not->toUse(['App\\Controllers', 'App\\Middleware']);<?php
arch('Test classes end with Test')
->expect('Tests')
->toHaveSuffix('Test');
arch('Interface classes end with Interface')
->expect('*\\Interfaces\\*')
->toHaveSuffix('Interface');<?php
// System should never depend on App
arch('System is independent from App')
->expect('System')
->not->toUse('App');
// Helpers should not depend on business logic
arch('Helpers are pure utilities')
->expect('Helpers')
->not->toUse(['App', 'Package']);End-to-end (E2E) tests verify complete workflows across multiple components, often involving real database operations and file system interactions.
| Type | Scope | Dependencies | Speed | Purpose | | | - | - | | - | | Unit | Single class/method | Mocked | Fast | Test logic in isolation | | Integration | Multiple classes | Real or mixed | Medium | Test component interaction | | E2E | Full workflow | Real | Slow | Test complete user scenarios |
The framework's TestCase class provides helpers for database testing:
<?php
use Tests\TestCase;
beforeEach(function () {
// Automatic database setup & transaction start
$this->refreshDatabase();
});
it('creates a user record', function () {
// Insert test data
DB::table('user')->insert([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => enc()->hashPassword('secret'),
'created_at' => date('Y-m-d H:i:s'),
]);
// Assert database state
$this->assertDatabaseHas('user', [
'email' => 'john@example.com',
]);
});For tests that modify the database, use transactions:
<?php
beforeEach(function () {
Database\DB::beginTransaction();
});
afterEach(function () {
Database\DB::rollBack();
});
it('processes payment and updates order', function () {
$order = createTestOrder();
$service = resolve(PaymentService::class);
$service->processPayment($order->id, 100.00);
$this->assertDatabaseHas('order', [
'id' => $order->id,
'status' => 'paid',
]);
// Automatically rolled back after test
});For tests involving file operations, use temporary directories:
<?php
beforeEach(function () {
$this->testDir = sys_get_temp_dir() . '/anchor_test_' . uniqid();
mkdir($this->testDir, 0755, true);
});
afterEach(function () {
$this->deleteDirectory($this->testDir);
});
it('copies package config files', function () {
// Arrange: Create test package structure
$packagePath = $this->testDir . '/TestPackage';
mkdir($packagePath . '/Config', 0755, true);
file_put_contents($packagePath . '/Config/test.php', '<?php return [];');
// Act: Copy files
$manager = resolve(PackageManager::class);
$count = $manager->publishConfig($packagePath);
// Assert
expect($count)->toBe(1);
expect(file_exists($this->testDir . '/App/Config/test.php'))->toBeTrue();
});For browser-based E2E tests, use the framework's browser helpers:
<?php
it('completes user registration flow', function () {
// Start browser session
$this->browse(function ($browser) {
$browser->visit('/register')
->type('name', 'Jane Doe')
->type('email', 'jane@example.com')
->type('password', 'secret123')
->type('password_confirmation', 'secret123')
->press('Register')
->assertPathIs('/dashboard')
->assertSee('Welcome, Jane');
});
// Verify database
$this->assertDatabaseHas('user', [
'email' => 'jane@example.com',
]);
});<?php
describe('Package Installation E2E', function () {
beforeEach(function () {
$this->testPackagePath = sys_get_temp_dir() . '/test_pkg_' . uniqid();
mkdir($this->testPackagePath . '/Config', 0755, true);
mkdir($this->testPackagePath . '/Database/Migrations', 0755, true);
// Create test package files
file_put_contents(
$this->testPackagePath . '/setup.php',
"<?php return ['providers' => ['Test\\\\Provider'], 'middleware' => []];"
);
file_put_contents(
$this->testPackagePath . '/Config/test.php',
"<?php return ['enabled' => true];"
);
});
afterEach(function () {
$this->deleteDirectory($this->testPackagePath);
});
it('completes full package installation workflow', function () {
$manager = resolve(PackageManager::class);
// Step 1: Publish configs
$configCount = $manager->publishConfig($this->testPackagePath);
expect($configCount)->toBeGreaterThan(0);
// Step 2: Publish migrations
$migrationCount = $manager->publishMigrations($this->testPackagePath);
expect($migrationCount)->toBeGreaterThan(0);
// Step 3: Register services
$manifest = $manager->getManifest($this->testPackagePath);
$manager->registerProviders($manifest['providers'] ?? []);
// Step 4: Verify installation status
$status = $manager->checkStatus($this->testPackagePath);
expect($status)->toBe(PackageManager::STATUS_INSTALLED);
// Step 5: Verify files exist
expect($this->fileMeta->exists($this->paths->appPath('Config/test.php')))->toBeTrue();
});
});- Keep them focused: Test one complete workflow per test
- Use realistic data: Mirror production scenarios
- Clean up thoroughly: Reset database and file system after each test
- Run selectively: E2E tests are slow, run them in CI or before deployment
- Document complex setups: Add comments explaining multi-step arrangements
For persistent processes like CLI Workers or Watchers, memory and state management are critical.
- No Static Accumulation: Avoid static arrays that grow indefinitely.
- Kernel Reset: Use
Kernel::terminate()to clear temporary caches. - State Cleanup: If your service holds state, implement a
reset()method.
// In your Service
public function reset(): void
{
$this->cache = [];
}
// In System/Core/Kernel.php
protected function terminate(): void
{
resolve(MyService::class)->reset();
}Maximize productivity and code quality by using the dock toolchain:
php dock format: Run this constantly to keep code clean.php dock inspect: Run before committing to catch PHPStan/Pint issues.php dock sail: Run as the final gatekeeper for tests and quality.
Anchor doesn't collapse because it's slow; it collapses because convenience is allowed to replace discipline. To build products that survive real growth, follow these scaling rules:
If a query returns "everything," it's a bug. Large datasets must always be constrained, paginated, or streamed using the framework's available tools.
Lazy loading should never be "accidental" at scale. If relationships are accessed, they must be explicitly planned, reviewed, and eager-loaded where necessary to avoid N+1 issues.
Any new filter, lookup, or foreign reference must ship with a database index. Indexing is not a follow-up task; it is part of the feature itself.
Anything slow, repeatable, or failure-prone (e.g., sending emails, external API calls, heavy processing) must run outside the user request cycle. Queues are mandatory once traffic exists.
If logic can be pushed to the database layer (SQL), it should be. Moving large datasets into memory for heavy collection work is a red flag for scalability.
No exceptions. If a screen or API endpoint lists data that can grow over time, it must implement pagination from day one.
Repeat reads require a caching strategy. Use the Cache helper to store expensive results. Database load should represent the source of truth, not a convenient workspace for temporary data.
Review query counts, execution time, and memory usage during development. Use the Benchmark helper to quantify performance before and after changes.
Be cautious with Accessors, Casts, Appended Attributes, and Global Scopes. While convenient, they are performance-sensitive and can hide expensive operations.
Set performance boundaries and scaling expectations for your codebase before the team or traffic doubles. Written discipline prevents technical debt.
Following these best practices ensures:
- Testable code through dependency injection
- Consistent patterns across the codebase
- Type safety with PHPStan static analysis
- Structural integrity with architecture tests
- Reliability through comprehensive E2E testing
Refer back to this guide whenever you're implementing new features or refactoring existing code.