PHP Guide¶
Applies to: PHP 8.1+, Laravel, Symfony, WordPress, APIs
Core Principles¶
- Modern PHP: Use PHP 8.1+ features (attributes, enums, readonly)
- Type Safety: Strict types, typed properties, return types
- PSR Standards: Follow PSR-1, PSR-4, PSR-12 coding standards
- Composer: Dependency management and autoloading
- Framework Best Practices: Follow Laravel/Symfony conventions
Language-Specific Guardrails¶
PHP Version & Setup¶
- ✓ Use PHP 8.1+ (8.3 recommended for new projects)
- ✓ Enable
declare(strict_types=1)in all files - ✓ Use Composer for dependency management
- ✓ Pin dependency versions in
composer.json - ✓ Include PHP version in
composer.jsonrequiresection
Code Style (PSR-12)¶
- ✓ Follow PSR-12 Extended Coding Style
- ✓ Run
php-cs-fixerorphpcsbefore every commit - ✓ Use
PascalCasefor classes - ✓ Use
camelCasefor methods and variables - ✓ Use
SCREAMING_SNAKE_CASEfor constants - ✓ 4-space indentation (not tabs)
- ✓ Line length: 120 characters max
- ✓ One class per file, file name matches class name
Type Safety (PHP 8+)¶
- ✓ Declare strict types:
declare(strict_types=1); - ✓ Type all method parameters and return values
- ✓ Use union types:
string|int - ✓ Use nullable types:
?stringorstring|null - ✓ Use typed properties in classes
- ✓ Use
mixedonly when truly necessary
Modern PHP Features (8.1+)¶
- ✓ Use enums for fixed sets of values
- ✓ Use readonly properties and classes
- ✓ Use constructor property promotion
- ✓ Use named arguments for clarity
- ✓ Use attributes instead of docblock annotations where possible
- ✓ Use match expressions instead of switch
- ✓ Use nullsafe operator:
$obj?->method() - ✓ Use first-class callable syntax:
$fn = strlen(...)
Error Handling¶
- ✓ Use exceptions for error handling (not return codes)
- ✓ Create custom exception classes for domain errors
- ✓ Catch specific exceptions, not generic
Exception - ✓ Always provide meaningful exception messages
- ✓ Log exceptions with context
- ✓ Don't suppress errors with
@
Security¶
- ✓ Never trust user input (validate and sanitize)
- ✓ Use prepared statements for database queries
- ✓ Use
htmlspecialchars()or templating engine escaping - ✓ Use
password_hash()andpassword_verify()for passwords - ✓ Use CSRF tokens for forms
- ✓ Set proper session configuration
- ✓ Keep dependencies updated
Project Structure¶
Laravel Standard¶
myproject/
├── app/
│ ├── Console/ # Artisan commands
│ ├── Exceptions/ # Custom exceptions
│ ├── Http/
│ │ ├── Controllers/
│ │ ├── Middleware/
│ │ └── Requests/ # Form requests (validation)
│ ├── Models/ # Eloquent models
│ ├── Providers/ # Service providers
│ └── Services/ # Business logic
├── config/ # Configuration files
├── database/
│ ├── factories/
│ ├── migrations/
│ └── seeders/
├── resources/
│ └── views/ # Blade templates
├── routes/
│ └── api.php
├── tests/
│ ├── Feature/
│ └── Unit/
├── composer.json
└── phpunit.xml
PSR-4 Autoloading¶
{
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\": "database/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
Validation & Input Handling¶
Laravel Validation¶
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
final class UserCreateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255', 'unique:users'],
'age' => ['required', 'integer', 'min:1', 'max:150'],
'role' => ['required', 'string', 'in:admin,user,guest'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'email.unique' => 'This email is already registered.',
'role.in' => 'Role must be admin, user, or guest.',
];
}
}
Symfony Validation¶
<?php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class UserCreate
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
#[Assert\Length(max: 255)]
public string $email,
#[Assert\Positive]
#[Assert\LessThan(150)]
public int $age,
#[Assert\Choice(choices: ['admin', 'user', 'guest'])]
public string $role,
) {}
}
Testing¶
Frameworks¶
- PHPUnit: Standard testing framework
- Pest: Modern, elegant syntax (Laravel-friendly)
- Mockery: Mocking library
- Laravel Dusk: Browser testing
Guardrails¶
- ✓ Test files:
*Test.phpintests/directory - ✓ Test methods:
test_*or@testannotation - ✓ Use descriptive names:
test_user_creation_fails_with_invalid_email() - ✓ Use data providers for parameterized tests
- ✓ Mock external services
- ✓ Coverage target: >80% for business logic
- ✓ Use database transactions for integration tests
Example (PHPUnit)¶
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Services\UserService;
use App\Repositories\UserRepository;
use App\Exceptions\ValidationException;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use Mockery;
final class UserServiceTest extends TestCase
{
private UserService $service;
private UserRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = Mockery::mock(UserRepository::class);
$this->service = new UserService($this->repository);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[Test]
public function creates_user_with_valid_data(): void
{
$data = [
'email' => 'test@example.com',
'age' => 25,
'role' => 'user',
];
$this->repository
->shouldReceive('create')
->once()
->with($data)
->andReturn(new User(1, ...$data));
$user = $this->service->create($data);
$this->assertSame('test@example.com', $user->email);
$this->assertSame(25, $user->age);
}
#[Test]
#[DataProvider('invalidEmailProvider')]
public function throws_exception_for_invalid_email(string $email): void
{
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('Invalid email');
$this->service->create([
'email' => $email,
'age' => 25,
'role' => 'user',
]);
}
/**
* @return array<string, array{string}>
*/
public static function invalidEmailProvider(): array
{
return [
'empty' => [''],
'no at sign' => ['invalid'],
'no domain' => ['test@'],
];
}
}
Example (Pest - Laravel)¶
<?php
declare(strict_types=1);
use App\Models\User;
use function Pest\Laravel\{postJson, assertDatabaseHas};
test('user can be created with valid data', function () {
$response = postJson('/api/users', [
'email' => 'test@example.com',
'age' => 25,
'role' => 'user',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response
->assertCreated()
->assertJsonPath('email', 'test@example.com');
assertDatabaseHas('users', ['email' => 'test@example.com']);
});
test('user creation fails with invalid email', function (string $email) {
$response = postJson('/api/users', [
'email' => $email,
'age' => 25,
'role' => 'user',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['email']);
})->with([
'empty' => '',
'invalid format' => 'not-an-email',
'missing domain' => 'test@',
]);
Tooling¶
Essential Tools¶
- PHP-CS-Fixer: Code formatting
- PHPStan / Psalm: Static analysis
- PHPUnit: Testing
- Composer: Dependency management
- Laravel Pint: Laravel code style (wrapper for PHP-CS-Fixer)
Configuration¶
<?php
// .php-cs-fixer.php
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/app')
->in(__DIR__ . '/tests');
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
'@PHP81Migration' => true,
'strict_param' => true,
'declare_strict_types' => true,
'array_syntax' => ['syntax' => 'short'],
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'final_class' => true,
'void_return' => true,
])
->setRiskyAllowed(true)
->setFinder($finder);
# phpstan.neon
parameters:
level: 8
paths:
- app
- tests
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
// composer.json scripts
{
"scripts": {
"lint": "php-cs-fixer fix --dry-run --diff",
"fix": "php-cs-fixer fix",
"analyse": "phpstan analyse",
"test": "phpunit",
"test:coverage": "phpunit --coverage-html coverage"
}
}
Pre-Commit Commands¶
# Format
composer fix
# Or Laravel
./vendor/bin/pint
# Static analysis
composer analyse
# Test
composer test
# Full check
composer lint && composer analyse && composer test
Common Pitfalls¶
Don't Do This¶
<?php
// No strict types
// Missing type declarations
function process($data) {
return $data;
}
// Using @ to suppress errors
$result = @file_get_contents($url);
// SQL injection
$query = "SELECT * FROM users WHERE id = " . $_GET['id'];
// XSS vulnerability
echo $_GET['name'];
// Loose comparison
if ($value == '0') { } // true for '', 0, null, false, []
// Not validating input
$user = User::create($_POST);
Do This Instead¶
<?php
declare(strict_types=1);
// Proper type declarations
function process(array $data): array
{
return $data;
}
// Proper error handling
$result = file_get_contents($url);
if ($result === false) {
throw new RuntimeException('Failed to fetch URL');
}
// Prepared statements
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
// Escaped output
echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
// Or in Blade: {{ $name }}
// Strict comparison
if ($value === '0') { }
// Validated input
$validated = $request->validated();
$user = User::create($validated);
Framework-Specific Patterns¶
Laravel Controller¶
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\UserCreateRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
final class UserController extends Controller
{
public function __construct(
private readonly UserService $userService,
) {}
public function index(): AnonymousResourceCollection
{
$users = User::query()
->with('profile')
->paginate(15);
return UserResource::collection($users);
}
public function store(UserCreateRequest $request): JsonResponse
{
$user = $this->userService->create($request->validated());
return UserResource::make($user)
->response()
->setStatusCode(201);
}
public function show(User $user): UserResource
{
return UserResource::make($user->load('profile'));
}
public function destroy(User $user): JsonResponse
{
$user->delete();
return response()->json(null, 204);
}
}
Laravel Service¶
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use App\Events\UserCreated;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
final class UserService
{
/**
* @param array{email: string, age: int, role: string, password: string} $data
*/
public function create(array $data): User
{
return DB::transaction(function () use ($data): User {
$user = User::create([
'email' => $data['email'],
'age' => $data['age'],
'role' => $data['role'],
'password' => Hash::make($data['password']),
]);
event(new UserCreated($user));
return $user;
});
}
}
Symfony Controller¶
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\UserCreate;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/users')]
final class UserController extends AbstractController
{
public function __construct(
private readonly UserRepository $userRepository,
) {}
#[Route('', methods: ['GET'])]
public function index(): JsonResponse
{
$users = $this->userRepository->findAll();
return $this->json($users);
}
#[Route('', methods: ['POST'])]
public function create(#[MapRequestPayload] UserCreate $dto): JsonResponse
{
$user = new User(
email: $dto->email,
age: $dto->age,
role: $dto->role,
);
$this->userRepository->save($user, flush: true);
return $this->json($user, Response::HTTP_CREATED);
}
#[Route('/{id}', methods: ['GET'])]
public function show(User $user): JsonResponse
{
return $this->json($user);
}
}
Enums (PHP 8.1+)¶
<?php
declare(strict_types=1);
namespace App\Enums;
enum UserRole: string
{
case Admin = 'admin';
case User = 'user';
case Guest = 'guest';
public function label(): string
{
return match ($this) {
self::Admin => 'Administrator',
self::User => 'Regular User',
self::Guest => 'Guest User',
};
}
public function permissions(): array
{
return match ($this) {
self::Admin => ['read', 'write', 'delete', 'admin'],
self::User => ['read', 'write'],
self::Guest => ['read'],
};
}
}
// Usage
$role = UserRole::from('admin');
echo $role->label(); // "Administrator"
Performance Considerations¶
Optimization Guardrails¶
- ✓ Use eager loading to avoid N+1 queries
- ✓ Use database indexes for frequently queried columns
- ✓ Use caching (Redis, Memcached) for expensive operations
- ✓ Use queues for long-running tasks
- ✓ Use pagination for large datasets
- ✓ Profile with Xdebug, Blackfire, or Laravel Telescope
- ✓ Enable OPcache in production
Example¶
<?php
// Eager loading
$users = User::with(['profile', 'orders'])->get();
// Query caching (Laravel)
$users = Cache::remember('users.active', 3600, function () {
return User::where('active', true)->get();
});
// Chunking for large datasets
User::query()
->where('created_at', '<', now()->subYear())
->chunkById(1000, function ($users) {
foreach ($users as $user) {
// Process user
}
});
// Queue job
dispatch(new SendWelcomeEmail($user));
Security Best Practices¶
Guardrails¶
- ✓ Always use
declare(strict_types=1) - ✓ Use prepared statements (Eloquent/Doctrine do this automatically)
- ✓ Escape output in templates
- ✓ Use
password_hash()withPASSWORD_DEFAULTorPASSWORD_ARGON2ID - ✓ Validate and sanitize all user input
- ✓ Use CSRF protection
- ✓ Set secure session settings
- ✓ Keep dependencies updated (
composer audit) - ✓ Use HTTPS in production
Example¶
<?php
declare(strict_types=1);
// Password hashing
$hash = password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 3,
]);
// Password verification
if (password_verify($input, $hash)) {
// Valid password
}
// Session security (php.ini or runtime)
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1');
ini_set('session.cookie_samesite', 'Strict');
ini_set('session.use_strict_mode', '1');
// Input sanitization
$email = filter_var($input, FILTER_SANITIZE_EMAIL);
$int = filter_var($input, FILTER_VALIDATE_INT);
// XSS prevention
$safe = htmlspecialchars($userInput, ENT_QUOTES | ENT_HTML5, 'UTF-8');