Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Security Laravel Package

graham-campbell/security

A Laravel package from Graham Campbell that helps harden your app by adding configurable security headers to HTTP responses. It provides a simple middleware-based setup for common browser protections, making it easy to improve response security with minimal configuration.

View on GitHub
Deep Wiki
Context7

Getting Started

Install

composer require graham-campbell/security

Laravel auto-discovery registers GrahamCampbell\Security\SecurityServiceProvider and the Security facade alias. If you're on a legacy setup without discovery, add them manually to config/app.php.

Publish (and read) the config first

php artisan vendor:publish --provider="GrahamCampbell\Security\SecurityServiceProvider"

You get config/security.php. This is the file that decides what "clean" actually means — read it before calling anything. It exposes the allow/deny knobs for tags, attributes, and permitted HTML.

First use case — the smallest viable win

Don't start with your richest WYSIWYG field. Start with a single plain-ish field where you render user content with {!! !!} — a comment body or a profile bio.

// A controller or form request
$validated['bio'] = security_clean($request->input('bio'));

Or through the facade:

use GrahamCampbell\Security\Facades\Security;

$clean = Security::clean($request->input('body'));
$escaped = Security::escape($request->input('name'));

First thing to check: render a realistic sample with links, an <img>, an emoji, and an <a target="_blank">. Confirm what survives. That single test tells you more about whether this package fits than any doc.

Global helpers available out of the box

  • security_clean($string) — sanitise one value
  • security_clean_array($array) — sanitise an array (shallow)
  • security_clean_recursive($array) — sanitise arrays nested arbitrarily deep

The recursive helper is the one you'll actually reach for when a request payload is an array of HTML strings.

Implementation Patterns

Do not scatter Security::clean() across the codebase

Wrap it once. That single call site survives a package swap (which you should assume is possible, given the low adoption numbers). A small helper or a form request trait:

namespace App\Support;

use GrahamCampbell\Security\Facades\Security;

final class Html
{
    public static function sanitize(?string $value): ?string
    {
        return is_null($value) ? null : Security::clean($value);
    }

    public static function sanitizeMany(array $values): array
    {
        return collect($values)
            ->map(fn ($v) => is_array($v) ? self::sanitizeMany($v) : self::sanitize($v))
            ->all();
    }
}

Every controller, job, importer, and artisan command talks to App\Support\Html — never to Security directly.

Sanitise on write, render escaped

The strongest pattern for anything user-authored:

// write time — sanitise into a dedicated, stored column
$post->body_html = Html::sanitize($request->input('body'));
$post->save();

// render time — still escape at the boundary
{!! $post->body_html !!}

Storing the sanitised result in a separate column (keep the raw input if you can) gives you a rollback story when you change the allowlist later. If you sanitise into the same column the raw input lived in, you've irreversibly lost data — plan around that.

Form request integration

protected function prepareForValidation(): void
{
    $this->merge([
        'body' => Html::sanitize($this->input('body')),
        'title' => Html::sanitize($this->input('title')),
    ]);
}

prepareForValidation runs before rules, so your required/max checks see the sanitised output. That's usually what you want — but note the length changes, so set max: with headroom.

Sanitise untrusted HTML that arrives from outside your forms

Third-party payloads are the underrated case: webhook bodies, email-in content, imported CMS exports, RSS. Sanitise these at ingestion, in the consuming job or command, not at render. The package is a leaf utility — a good fit for a queued job:

public function handle(): void
{
    $feed->items()->each->update([
        'content' => Html::sanitize($item->content),
    ]);
}

This also keeps the CPU cost off the request path — handy for bulk imports.

The escape half is usually redundant

Security::escape() / security_escape() exists, but if your output goes through Blade {{ }} you already have contextual escaping. Reach for it only in non-Blade paths: mailables built from string templates, PDF/HTML renderers, API responses that inline HTML fragments. Everywhere else, prefer {{ }}.

Per-field allowlists

If your config exposes the tag/attribute settings, resist the temptation to make a single global policy fit every field. Two profiles — "comment" (p, a, strong, em, ul/ol/li) and "article" (adds headings, blockquote, tables) — are more defensible than one maximal allowlist. Publish once, then define the profiles in your own config and map into the facade call, or maintain distinct config snapshots per surface.

Gotchas and Tips

"Sanitised" ≠ "safe to output raw" — the biggest day-to-day trap

Teams adopt the package, call Security::clean(), and then confidently start using {!! !!} everywhere. The correct mental model: sanitisation removes dangerous HTML from the string; escaping handles where the string ends up (attributes, JS, URLs, JSON). Keep the CSP, keep contextual escaping, keep {{ }} as the default. If you find yourself relaxing those because the sanitiser is in place, the sanitiser has made you less safe.

Confirm what clean vs escape actually do against upstream

The wrapper doesn't document its fidelity to voku/anti-xss. Before relying on it, run a spike:

dd(Security::clean('<script>alert(1)</script><p onclick="x()">hi</p>'));

Does it strip or encode the payload? Does it return <script> as &lt;script&gt;, or remove it entirely? The answers change your rendering assumptions. Write the finding down and pin it to the package version.

Content destruction is silent

Over-eager filtering mangles legitimate markup — tables, target="_blank", RTL text, emoji, non-Latin scripts, embedded media. It does not throw. It shows up as support tickets three weeks later. Before rollout, run your actual existing content through the sanitiser as a diff:

$before = Post::pluck('body', 'id');
$after  = $before->map(fn ($b) => Html::sanitize($b));
// diff and eyeball what changed

Do this before you ship, not after.

Idempotency assumptions

Don't call clean() repeatedly on the same value in a pipeline. Each pass may re-encode entities (&amp; → &amp;amp;), which is invisible until someone searches for the string. Sanitise exactly once, at the boundary, and pass the result through.

Config drift on upgrade

You've published config/security.php, so a package bump that changes defaults won't reach you — the published file shadows the package's version. That's a double-edged sword: safe from surprise changes, but you'll silently keep stale (possibly weaker) rules. Re-diff your config against the package's on every major bump:

diff vendor/graham-campbell/security/config/security.php config/security.php

Constraint lockout risk

The 11.x line pins illuminate/* to ^11.0. If you're planning a framework upgrade, check that constraint now, not on the day you try to bump Laravel — a narrow constraint here can block an entire upgrade. Treat a package major bump as framework-coupling, not as an API signal.

Bus factor mitigation

Single-maintainer, thin-wrapper, low adoption. Acceptable for a leaf utility called through one internal helper — unacceptable as the sole XSS control on a regulated product. The mitigation is architectural: keep the wrapper (App\Support\Html) thin enough that swapping it for mews/purifier or symfony/html-sanitizer is an afternoon's work. Don't leak upstream types or exceptions into your domain code.

Debugging checklist when output looks wrong

  1. Did the value pass through clean() twice? (entity double-encoding)
  2. Is the config cached? php artisan config:clear after editing config/security.php — the classic "I changed the allowlist and nothing happened" moment.
  3. Is the field going through Blade {{ }} and Security::escape()? Double-escaping renders &lt;p&gt; visibly.
  4. Is the value arriving from a JSON body where security_clean_recursive is needed but you called the shallow helper? Nested arrays come back uncleaned and it's easy to miss.

Don't wire it into middleware or the HTTP kernel

Tempting, and wrong for two reasons: you'll sanitise fields that shouldn't be touched (passwords, tokens, JSON APIs), and you'll destroy the ability to have per-surface policies. Keep it a leaf call in a request/job/command.

Scope statement

Write one line in your repo: "This package sanitises untrusted HTML on write. It is not a WAF, not CSRF protection, and does not replace output escaping or CSP." It sounds obvious. It is not, and it's the single most useful artefact to keep adoption from drifting into false confidence.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console