# PHP Security

> When to activate: PHP security, SQL injection, PDO prepared statements, mass assignment, CSRF, Blade XSS, password_hash, session fixation, unserialize object injection, file upload validation, Laravel security

- Skill: `mattakushi432/php-security` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mattakushi432/php-security`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mattakushi432/php-security/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: Mattakushi432 (https://skillmd.com/u/mattakushi432)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mattakushi432/php-security

---


# PHP Security Patterns

## When to Use

Reviewing or writing PHP/Laravel code that touches raw SQL, user input, authentication, file uploads, or serialized data.

## Core Patterns

### Prepared Statements, Never Interpolation

```php
// BAD: string interpolation — classic SQL injection
$pdo->query("SELECT * FROM users WHERE email = '{$email}'");

// GOOD: bound parameters
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
```

Eloquent/query builder methods bind parameters automatically — the risk appears when developers drop to `DB::select()`/`DB::statement()` with raw strings.

```php
// BAD
DB::select("SELECT * FROM orders WHERE status = '$status'");

// GOOD — still parameterized even in raw SQL
DB::select('SELECT * FROM orders WHERE status = ?', [$status]);
```

### Mass-Assignment Protection

```php
// BAD: everything in the request can set model attributes, including is_admin
class User extends Model {
    protected $guarded = [];
}

// GOOD: explicit allow-list
class User extends Model {
    protected $fillable = ['name', 'email', 'password'];
}
```

Never call `->fill($request->all())` or `User::create($request->all())` — always pass `$request->validated()` from a `FormRequest`.

### Password Hashing

```php
// Storing
$hashed = password_hash($plainPassword, PASSWORD_BCRYPT);
// Laravel:
$hashed = Hash::make($plainPassword);

// Verifying — constant-time comparison built in, never use ===
if (password_verify($plainPassword, $hashed)) { /* ... */ }
if (Hash::check($plainPassword, $hashed)) { /* ... */ }
```

Never roll a custom hash (`md5`, `sha1`) or store plaintext. Rehash on login if `password_needs_rehash()` returns true after a cost-factor bump.

### Blade Output Escaping

```blade
{{-- GOOD: auto-escaped, safe by default --}}
<p>{{ $comment->body }}</p>

{{-- DANGEROUS: raw output, only for trusted/pre-sanitized HTML --}}
<div>{!! $trustedCmsContent !!}</div>
```

If user input must render as HTML, sanitize it first with a vetted library (e.g. `HTMLPurifier`) — never `{!! !!}` a raw user-supplied field.

### CSRF Protection

```blade
<form method="POST" action="/orders">
    @csrf
    ...
</form>
```

```php
// API routes using tokens/sessions still need CSRF unless purely stateless
// with a Bearer token — verify VerifyCsrfToken middleware covers all
// state-changing routes that use cookie-based auth.
```

### Safe Deserialization

```php
// BAD: unserialize() on user input can trigger PHP object injection
$data = unserialize($_COOKIE['data']);

// GOOD: use a data-only format
$data = json_decode($_COOKIE['data'], true, flags: JSON_THROW_ON_ERROR);
```

### File Upload Validation

```php
$request->validate([
    'avatar' => ['required', 'file', 'image', 'max:2048', 'mimes:jpg,png,webp'],
]);

// Store outside the public webroot when possible, or with a randomized name
$path = $request->file('avatar')->store('avatars', 'private');
```

Never trust the client-supplied MIME type or extension alone — validate the actual file content (`image` rule inspects real image data).

## Checklist

- [ ] No raw SQL string interpolation anywhere in the codebase
- [ ] Models use `$fillable`, never `$guarded = []`
- [ ] Passwords hashed with `password_hash`/`Hash::make`, never a fast general-purpose hash
- [ ] `{!! !!}` / raw HTML output only used on sanitized or trusted content
- [ ] CSRF middleware active on all cookie-authenticated state-changing routes
- [ ] `unserialize()` never called on user-controlled input
- [ ] File uploads validated by content type and size, stored outside public path when sensitive
- [ ] Secrets loaded from `.env`/`config()`, never hardcoded

## Anti-Patterns

```php
// BAD: trusting $_GET/$_POST directly in a redirect (open redirect)
header('Location: ' . $_GET['next']);

// GOOD: validate against an allow-list of known internal paths
$next = in_array($request->get('next'), $allowedRedirects, true)
    ? $request->get('next')
    : '/dashboard';
header("Location: {$next}");
```

## Quick Reference

| Risk | Mitigation |
|---|---|
| SQL injection | Prepared statements / query builder binding |
| Mass assignment | `$fillable` allow-list + `FormRequest` validation |
| XSS | Blade `{{ }}` auto-escaping; sanitize before `{!! !!}` |
| CSRF | `@csrf` + `VerifyCsrfToken` middleware |
| Object injection | `json_decode` instead of `unserialize` |
| Weak password storage | `password_hash` / `Hash::make` (bcrypt/argon2) |

## See Also

- `skills/php-ecosystem/laravel-patterns.md`
- `skills/php-ecosystem/php-database.md`
- `skills/security/owasp-checklist.md`

