Horizon + Telescope — observability in Laravel
Concept
Security best practices in Laravel cover common vulnerabilities and how the framework mitigates them — or where you must be careful.
SQL Injection: Eloquent and Query Builder use PDO parameter binding by default. You're safe with ->where('column', $value). You're UNSAFE with ->whereRaw("column = '$value'"). Always use bindings: ->whereRaw('column = ?', [$value]) or ->whereRaw('column = :val', ['val' => $value]).
XSS (Cross-Site Scripting): Blade auto-escapes output with {{ $value }} (runs htmlspecialchars()). {!! $value !!} is UNESCAPED — only use for trusted HTML content. Never output unescaped user input.
CSRF: Laravel's web middleware group includes VerifyCsrfToken. All POST, PUT, PATCH, DELETE forms need @csrf. API routes (using api middleware group) are stateless — no CSRF needed.
Mass Assignment: Always declare $fillable or $guarded on models. User::create($request->all()) is dangerous — a user could pass is_admin=1. Use $request->validated() (from Form Request) which only includes validated fields.
Authentication: Use Laravel Sanctum or Passport, not roll-your-own tokens. Hash passwords with bcrypt() or Hash::make(). Never store plaintext passwords. Rate limit login routes.
Authorization: Always check $this->authorize(...) or use Policies. A missing authorization check is a security hole. Use php artisan route:list to audit which routes have middleware.
Environment: Never commit .env. Use .env.example for defaults. In production, set APP_DEBUG=false and APP_ENV=production. Debug mode leaks stack traces.
Code Example
<?php
// SQL Injection
// UNSAFE
DB::select("SELECT * FROM users WHERE email = '{$request->email}'"); // injectable!
DB::table('users')->whereRaw("email = '{$request->email}'"); // injectable!
// SAFE
DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);
DB::table('users')->where('email', $request->email); // safe, uses binding
DB::table('users')->whereRaw('email = ?', [$request->email]); // safe with binding
// XSS
// Blade — SAFE (auto-escaped)
// {{ $comment->body }} → <script>...</script>
// Blade — UNSAFE (raw HTML)
// {!! $trustedHtml !!} → use ONLY for trusted/sanitized content
// Sanitize rich text if using {!! !!}
$cleanHtml = \Illuminate\Support\HtmlString::fromString(
strip_tags($userInput, '<p><br><strong><em>')
);
// Mass Assignment
// UNSAFE
User::create($request->all()); // user might pass 'is_admin=1'
// SAFE — use validated() from FormRequest (only validated fields) or explicit array
User::create($request->only(['name', 'email', 'password']));
User::create($request->validated()); // only fields declared in rules()
// Authorization — always check!
class PostController extends Controller
{
public function update(Request $request, Post $post): \Illuminate\Http\Response
{
$this->authorize('update', $post); // throws AuthorizationException if denied → 403
$post->update($request->validated());
return response()->noContent();
}
}
// Rate limiting for auth routes (already in Laravel defaults)
// RateLimiter::for('login', fn(Request $r) => Limit::perMinute(5)->by($r->ip()));
// Sanctum token — hashed in database
$token = $user->createToken('api-key')->plainTextToken; // return once, hash stored