bakslash/static-site-bundle
A Symfony bundle for exporting your application as a static site. It crawls configured routes or uses a URL list, saves rendered HTML to disk, and helps automate static builds for fast, low-cost hosting.
This is a Laravel static-site generator: you build routes with normal Laravel controllers, then prerender them to files. Start by installing and publishing config:
composer require <vendor>/<package> # requires PHP 8.3, Symfony 7.4 / 8.x
php artisan vendor:publish # look for the static-site config file first
Where to look first is the config file. Three keys drive almost everything:
base_uri — the absolute prefix baked into links in the output. Set this before your first build; relative-link surprises are the #1 source of "why do my images 404".clean_urls — controls whether pages are written as <path>/index.html (true) or <path>.html (false).Your first use case — a marketing page and a blog index:
#[Prerender]
public function home() { ... }
#[Prerender]
public function about() { ... }
Then preview and build:
php artisan static-site:list # shows what would be written, no files touched
php artisan static-site:generate # actually writes the site
Run static-site:list first every time. It's the cheap sanity check that catches wrong paths, missing params, and mis-configured base_uri before you push to S3.
Class-level vs method-level #[Prerender]. Put the attribute on the class to prerender every action, or on individual methods for surgical control. For an "all static" controller this is a one-line win; for controllers that mix static pages with dynamic endpoints (login POSTs, APIs), use method-level to avoid accidentally baking dynamic responses into files.
Parameterised routes via ParamsProviderInterface. For route parameters (/blog/{slug}, /products/{category}/{id}), implement the provider interface as a service and return the parameter sets to render. This is the cleanest pattern for blog indexes, product catalogues, and anything backed by a model — the provider becomes your single source of truth for "what gets generated". For small, fixed sets you can skip the service entirely and declare an inline list of parameter sets. Use the provider when the list is data-driven; use the inline list for hand-curated cases like a fixed set of landing pages.
Scoped builds with --route / --path globs. Both flags are repeatable and accept globs. This is your fast-iteration loop:
php artisan static-site:generate --route='/blog/*' --path='/blog/*'
Use --route to limit which routes are considered and --path to limit which output paths are written. Handy for incremental rebuilds in CI after a content edit, or while debugging a single page.
Deployment targets via Flysystem. Because the dumper writes through a Flysystem operator, you point the config at an S3/FTP operator service and get remote deploys with zero custom code. Keep local for development, swap the filesystem binding per environment.
Redirects as static stubs. Redirect responses are emitted as meta-refresh HTML stubs. That means redirects work on any dumb static host without server config — but they are not HTTP 301s. Plan your SEO expectations accordingly; if you need real 301s you'll need the host's rewrite layer in front.
Minification. Optional HTML and XML minification comes via sensiolabs/minify-bundle. It's opt-in and requires the bundle to be installed and enabled — don't assume it's on. Wire it into the minify bundle's config rather than the static-site config.
Event hooks for post-processing. PageRenderedEvent, PageDumpedEvent, and PageFailedEvent fire per page during a run. Use PageRenderedEvent to inspect/patch HTML before it's written, PageDumpedEvent to build a search index or sitemap as pages complete, and PageFailedEvent to report failures to your monitoring rather than relying on exit codes alone.
Service redefinition. The renderer, dumper, URI provider, and filesystem are all swappable by redefining their service IDs in a service provider. This is the extension point for custom output formats, non-standard storage, or bespoke URL logic — you don't need to fork the package.
PHP 8.3 is a hard floor. The attribute syntax and internal code rely on it; don't try this on an older runtime even if composer resolution seems to succeed on a stale lockfile.
Non-2xx fails the page — deliberately. Only 2xx and redirects are supported. 404s, 403s, and 500s will fail the build for that page. That's a feature (you don't silently ship broken pages), but it means any controller that can legitimately return a non-2xx needs an explicit strategy — either ensure error paths aren't #[Prerender]-marked or handle them before the renderer sees them.
base_uri is baked in at build time. Get the scheme + host + base path right per environment, or every absolute link, canonical tag, and asset URL in the output will be wrong. If you deploy under a subpath (https://example.com/docs/), include the trailing path here — this is exactly what "may include a base path" is for. Rebuild after changing it; edits to the built files won't propagate.
clean_urls changes the output tree, not just names. Toggling between blog/post.html and blog/post/index.html changes relative asset references unless they're rooted or absolute. Pick one and stick to it per project; mixing pre-built output from both modes in one bucket produces confusing 404s.
Glob behaviour is per-flag and repeatable. --route and --path are independent filters; a page must survive both to be written. Passing only --route still filters on path (default is everything), so test with static-site:list — it accepts the same flags and shows the intersection without side effects.
Redirect stubs are HTML, not headers. Meta-refresh is honoured by browsers but not by crawlers in the same way as a 301. Don't rely on these for canonical consolidation.
Minification needs the bundle installed and enabled. If the bundle is present but not enabled in its own config, nothing is minified and there's no error — just larger output. Confirm by diffing file sizes after a build.
Params provider must be registered as a service. ParamsProviderInterface implementations are resolved from the container by the generator. If it isn't bound (or autowired) correctly, the parameterised routes simply get no params and either fail or emit malformed paths — check static-site:list output carefully for empty or repeated segments.
Watch per-page event volume. Events fire for every page across the run, so keep listeners cheap. If you need aggregated work (sitemaps, search index), buffer in the listener and flush at run end rather than doing expensive work per event.
Swapping services means owning their contracts. Redefining a service ID is powerful but you inherit any behavioural assumptions the package makes about the original (return shapes, ordering, error semantics). The renderer and dumper are the safest to customise; changing the URI provider affects both discovery and link generation — test against static-site:list before generating.
How can I help you explore Laravel packages today?