TL;DR — A Laravel package that fetches a stylesheet, a script, or a webfont from a third-party host isn't shipping a UI. It's shipping a policy requirement that every host application has to satisfy. And since CSP is usually enabled in production only, that requirement is invisible on your machine and non-negotiable on theirs.
The bug that only exists in production
laravel-artisan-runner gives you a web UI for running whitelisted Artisan commands. Its layout used to start like this:
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'media',
theme: { extend: { fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] } } }
}
</script>
Works beautifully. On my machine. On yours. On every reviewer's machine.
Then it shipped into a host application that sets script-src 'self' in production — and only in production, because a strict CSP locally would break the Vite dev server. The browser refused the CDN script, and the console said so:
Loading the script 'https://cdn.tailwindcss.com/' violates the following
Content Security Policy directive: "script-src 'self' …"
The failure mode was not "slightly unstyled". Tailwind's browser build is what sizes everything, so the header logo — constrained only by h-9 w-9 — rendered at its natural SVG size and filled the viewport. And the inline tailwind.config = {...} immediately after the blocked script threw ReferenceError: tailwind is not defined, taking the rest of that script block with it.
One refused request, two visible failures, zero of them reproducible in dev.
Fix one: ship the stylesheet
The real answer is that the package's UI should be styled by a file the package owns:
@if (file_exists(public_path('vendor/artisan-runner/artisan-runner.css')))
<link rel="stylesheet" href="{{ asset('vendor/artisan-runner/artisan-runner.css') }}">
@else
<style>{!! file_get_contents(ArtisanRunnerServiceProvider::DIST_PATH.'/artisan-runner.css') !!}</style>
@endif
The two-branch pattern matters. vendor:publish is a thing the host application has to remember to do, and a package UI that renders naked because someone skipped a publish step is a support ticket, not a feature. So: use the published file if it's there, inline the compiled one from the package if it isn't. The logos in this package already worked that way; the stylesheet just joined them.
Worth naming out loud: cdn.tailwindcss.com is documented by Tailwind as development-only. It compiles Tailwind in the browser on every page load. Even without CSP, it makes your package's render path depend on a third party answering.
Fix two: the fonts were the same bug, one directive over
Shipping the stylesheet in 1.2.4 left this in the head:
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700|jetbrains-mono:400,500" rel="stylesheet" />
I'd deliberately left the fonts remote and written a comment justifying it: webfonts are progressive enhancement, the stacks fall back to system-ui, so losing them costs typography and never layout.
That reasoning is correct and completely beside the point. A host running style-src 'self' 'unsafe-inline' refuses that <link> outright. It wasn't degrading gracefully in production — it was never arriving. Same class of failure as the CDN script, one directive over, hidden behind a comment that explained why it was fine.
So the latin subsets of Inter (400/500/600/700) and JetBrains Mono (400/500) now ship in resources/dist/fonts — six woff2 files, about 144 KB — declared by the package's own stylesheet with relative URLs that resolve under /vendor/artisan-runner/. font-src 'self' covers them without the host adding a single directive.
144 KB is a real cost. I'll take it over a UI that arrives unstyled in the one environment that matters.
The test is the actual deliverable
Here's the thing about a defect that only appears under a policy you don't run locally: fixing it teaches you nothing if the next contributor can reintroduce it and watch every check go green.
So the rule got encoded:
it('requests no asset from a third-party host', function () {
// A host application running `default-src 'self'` refuses every one of
// them, and it does so only in production — which is why this shipped
// working on every developer machine.
$views = array_merge(
glob(__DIR__.'/../resources/views/**/*.blade.php'),
glob(__DIR__.'/../resources/views/*.blade.php'),
);
foreach ($views as $view) {
$markup = preg_replace('/\{\{--.*?--\}\}/s', '', file_get_contents($view)) ?? '';
expect($markup)->not->toMatch('#(src|href)=[\'"]https?://#');
}
});
it('self-hosts the webfonts it declares', function () {
$dist = ArtisanRunnerServiceProvider::DIST_PATH;
$css = file_get_contents($dist.'/artisan-runner.css');
expect($css)->toContain('@font-face');
foreach (glob($dist.'/fonts/*.woff2') as $font) {
expect($css)->toContain('fonts/'.basename($font));
}
});
Two things I'd point at if this were a code review:
It strips Blade comments before matching. The layout is full of {{-- … --}} blocks explaining why the CDN URL is gone — and those blocks contain the URL. A naive regex over the raw file fails on its own documentation. Comment-stripping isn't a nicety here; without it the test punishes you for explaining yourself.
It asserts the shape, not the render. Nothing boots a browser. Nothing needs a CSP header. The assertion is "no view declares a remote asset", which is a property you can check statically and which holds regardless of what any host's policy happens to say. The whole reason the bug survived review is that the behaviour is environment-dependent — so test the thing that isn't.
The font test is the pair to it: the first test says nothing remote goes out, the second says the local replacement is actually wired. Delete either one and you can ship a page that references fonts that don't exist, or fonts that exist and nothing references.
The packaging bit
Build sources are export-ignored in .gitattributes:
/resources/css export-ignore
/package.json export-ignore
/package-lock.json export-ignore
The tarball needs the compiled resources/dist, not the pipeline that produced it.
And the flip side, which the CSS source now says in a comment at the top: npm run build has to be re-run after touching any Blade view in the package. The stylesheet is generated by scanning those views —
@import 'tailwindcss';
/* The package's own views are the only source of classes. */
@source '../views/**/*.blade.php';
— so forget the rebuild and you ship a stylesheet missing the utility you just used. Which fails exactly like the CDN did, only quieter.
Takeaway
If you maintain a Laravel package with any UI at all, go read its layout right now and ask one question of every src= and href=: does this work under default-src 'self'?
If the answer is no, you haven't shipped a package. You've shipped a package plus a CSP amendment, and you've made it the host application's job to discover that — in production, from a console log, on a page that's already broken.
Self-contained is the contract. Everything the UI needs travels in the tarball.
Next up on my list: the same audit across the other packages. This one won't be the only offender.
Top comments (0)