bristol-digital/qwikblog
File-based, headless-friendly blog for Laravel with no database required. Write posts as Markdown with YAML front matter in resources/posts. Includes lightweight admin with Livewire image gallery and WYSIWYG editor, plus public index and post views.
A simple, file-based blog for Laravel — headless front-end + lightweight admin
with a Livewire-powered image gallery and a WYSIWYG body editor. No database
required. Posts live in resources/posts/ as Markdown files with YAML front
matter.
composer require bristol-digital/qwikblog
The package's service provider auto-registers via Laravel's package discovery. Then publish the config and the admin's JS entry point:
php artisan vendor:publish --tag=qwikblog-config
php artisan vendor:publish --tag=qwikblog-admin-js
The admin JS publish puts resources/js/qwikblog-admin.js in your host app —
a tiny Vite entry that imports Toast UI Editor for the post form's body field.
Wire it into your Vite build (vite.config.js):
input: [
'resources/css/app.css',
'resources/js/app.js',
'resources/js/qwikblog-admin.js', // <-- add this
],
Install the npm dependency that the admin JS imports:
npm install @toast-ui/editor
Note: the package can't auto-install its own npm dependencies because Composer and npm are separate ecosystems. Every Laravel package that uses JS libraries works this way —
composer requirefor PHP,npm installfor JS, no automatic bridge between them.
Set admin credentials in .env (see Configuration):
ADMIN_USERNAME=your-username
ADMIN_PASSWORD=your-password
Make sure your host app's Tailwind setup includes the package's blade files
in its content scanning (so admin styling Just Works) and has the typography
plugin and Alpine for the public views. In resources/css/app.css (or your
Tailwind config):
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "../../vendor/bristol-digital/qwikblog/resources/views/**/*.blade.php";
In resources/js/app.js:
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
The package's public views (post index, single post, etc.) extend a
layout in your host app. Which value of QWIKBLOG_LAYOUT to set in .env
depends on what your project has:
If you have resources/views/app.blade.php — QWIKBLOG_LAYOUT=app
If you have resources/views/layouts/app.blade.php — QWIKBLOG_LAYOUT=layouts.app
If you have neither yet (common on fresh Laravel 11+ installs) — publish the package's bundled starter layout and point at it:
php artisan vendor:publish --tag=qwikblog-views
Then in .env:
QWIKBLOG_LAYOUT=vendor.qwikblog.app
The bundled starter is clean and functional; you can edit it
(resources/views/vendor/qwikblog/app.blade.php) or replace it later
with your own layout.
Whichever layout you use, make sure it has @stack('head') inside <head>
so the package can inject SEO meta. To enable RSS autodiscovery, also add:
<link rel="alternate" type="application/rss+xml" title="{{ config('app.name') }}" href="{{ url('/blog/feed.xml') }}">
Finally:
npm run dev
php artisan serve
To populate with the bundled flamenco demo content (12 posts across 4 categories with images):
php artisan vendor:publish --tag=qwikblog-seeds
php artisan blog:examples flamenco
Visit / to see the blog. Visit /admin to log in.
| Route | Description |
|---|---|
/ and /blog |
Post index — both serve the same content |
/blog/{slug} |
Single post or category filter or tag filter (resolves in that order) |
/blog/search?q=... |
Full-text search results |
/blog/author/{slug} |
All posts by an author |
/blog/{year} |
All posts from a year |
/blog/{year}/{month} |
All posts from a month (e.g. /blog/2024/11) |
/blog/category/{slug} |
Category filter — explicit prefix, always works |
/blog/tag/{slug} |
Tag filter — explicit prefix, always works |
/blog/feed.xml |
RSS 2.0 feed |
/sitemap.xml |
XML sitemap |
Public views live at resources/views/blog/index.blade.php and
show.blade.php. Both extend the host site's app.blade.php layout. The
package ships a starter app.blade.php; host sites typically have their
own and only need @stack('head') somewhere in <head> for SEO meta to
inject correctly.
Posts are stored as resources/posts/YYYY-MM-DD-slug.md. Front matter:
---
title: My First Post
subtitle: An optional subtitle
summary: Short excerpt — used on listings.
categories: Announcements, News
tags: launch, hello-world
hero_image: /images/blog/my-first-post/1.jpg
author: Jane Editor
date: 2024-11-15 10:00:00
---
Markdown body goes here.
| Field | Required | Notes |
|---|---|---|
title |
yes | Determines the URL slug |
subtitle |
no | Shown on cards and post header |
summary |
yes | Short excerpt for listings, OG description |
categories |
no | Comma-separated; multi-valued |
tags |
no | Comma-separated; multi-valued |
hero_image |
no | Path under public/; falls back to first gallery image |
author |
yes | Byline; links to /blog/author/{slug} |
date |
yes | YYYY-MM-DD or YYYY-MM-DD HH:MM:SS; future = scheduled |
The admin writes this format automatically. The legacy singular category:
field is still read for back-compat with older posts.
List values — inline or multi-line. Categories and tags can be written either inline (as above) or as multi-line YAML lists; both parse identically:
categories: - Announcements - News tags: - launch - hello-worldThe admin always writes the inline form. The multi-line form is supported for hand-authored or pasted-in content — useful when working with AI-generated drafts or copying from snippets that use standard YAML list syntax.
Posts can have any number of each. Both render in the right-rail sidebar
on the index page (categories as a vertical list, tags inline as
#hashtag chips), each with a post count. Active filter is highlighted.
| URL | Result |
|---|---|
/ or /blog |
All published posts |
/blog/palos |
Posts in Palos (flat URL style, the default) |
/blog/category/palos |
Same — explicit prefix; always works regardless of style |
/blog/gitano |
Posts tagged gitano |
/blog/tag/gitano |
Same — explicit prefix |
One filter at a time — clicking a different chip replaces the active filter rather than stacking with AND. Each post's own categories and tags also appear in the show-page sidebar with counts.
If no published post has any categories, the categories section hides
entirely. Same for tags. The view checks count(...) > 0 before
rendering.
Posts win on slug collision. A post titled "Palos" would shadow the Palos
category filter at /blog/palos. If you need strict separation, set
QWIKBLOG_TAXONOMY_URL_STYLE=prefixed — chip links then go to
/blog/category/palos etc. and never collide with post slugs.
Set date to a future timestamp. The post will:
wire:poll/blog until the date passes/blog/search?q=... runs an AND match across title, subtitle, summary,
author, categories, tags, and rendered body content. Multi-term queries
are supported (jerez bulerías returns posts containing both terms).
Pure-PHP str_contains — fine up to a few thousand posts; past that, swap
in a real index.
Search results pages are noindex by default — every unique query is a
unique URL and not worth indexing.
/blog/author/{slug} lists every published post by an author. The author
byline on each show page links here automatically.
/blog/{year} and /blog/{year}/{month} return posts from that period.
The sidebar archive nav appears when at least 2 distinct months are
represented; below that it stays hidden (a single-month archive is just
the index).
Each post page shows up to 3 related posts at the bottom, scored by taxonomy overlap:
score = (shared_tags × 2) + (shared_categories × 1)
Posts scoring 0 are excluded entirely — better to hide the section than show weak recommendations. Weights and limit are configurable.
| Feature | Where |
|---|---|
| RSS 2.0 feed | /blog/feed.xml (most recent 50 posts) |
| Sitemap | /sitemap.xml (every post + filter URL + author + archive) |
Autodiscovery <link> |
In every page's <head> |
| Open Graph + Twitter Card | On every post page |
| Canonical URL | Every page |
| Reading time | "X min read" on post pages, computed at 200 wpm by default |
| Robots noindex | Search results, scheduled-post admin previews |
Reference the sitemap from public/robots.txt:
Sitemap: https://your-site.test/sitemap.xml
Each post has its own folder at public/images/blog/{slug}/. Images are
numbered 1.jpg, 2.jpg, … and ordering is set by the numeric filename.
The Livewire gallery (/admin/posts/{slug}/images) lets you:
hero_image fieldIf hero_image is left blank in the front matter, the first numerically-
named image is used automatically. Set it explicitly to override.
When a post is renamed, its image folder moves with it. When a post is deleted, its image folder is deleted too.
On the public show page, multiple images render as a crossfading carousel with prev/next, dot navigation, a counter, and a thumbnail strip.
The admin's body field uses Toast UI Editor,
defaulting to WYSIWYG mode with a Markdown toggle in the toolbar. The
editor is bundled via Vite from resources/js/qwikblog-admin.js (published
into your host app on install — see Installation) — no
CDN dependency at runtime. Posts are stored as plain CommonMark, so files
remain hand-editable.
If you don't run the editor and just want plain markdown editing, delete
resources/js/qwikblog-admin.js and remove @vite(['resources/js/qwikblog-admin.js'])
from the admin layout (publish the views via vendor:publish --tag=qwikblog-views
to override). The textarea behind the editor stays usable as a markdown
fallback in any case.
A small admin lives at /admin (or whatever path you set via QWIKBLOG_ADMIN_PATH).
The package's admin protection is configurable via QWIKBLOG_ADMIN_MIDDLEWARE. By default it uses the package's own self-contained auth (single shared username/password from .env). For sites with their own auth system, you can wire the blog admin into that instead so users only log in once.
Option 1 — Self-contained auth (default)
Set credentials in .env:
ADMIN_USERNAME=your-username
ADMIN_PASSWORD=your-password
If either is empty, login is refused — there is no default password. Visit /admin/login to log in. This is the right choice for static sites, no-database deployments, or any host that doesn't already have a login system.
Option 2 — Integrate with Laravel's auth
If your host app already has Laravel auth set up (Breeze, Jetstream, Fortify, Sanctum, or a starter kit's bundled login), tell the package to use that instead:
QWIKBLOG_ADMIN_MIDDLEWARE=auth
QWIKBLOG_ADMIN_LOGOUT_ROUTE=logout
Now any user logged in to your host app can access the blog admin at /admin (or QWIKBLOG_ADMIN_PATH). The logout button in the admin chrome calls your host's logout endpoint. You can ignore ADMIN_USERNAME / ADMIN_PASSWORD entirely.
To restrict the blog admin to specific users (not just any authenticated user), combine with a Laravel gate:
QWIKBLOG_ADMIN_MIDDLEWARE=auth,can:manage-blog
Then define the gate in your host app's AppServiceProvider:
Gate::define('manage-blog', fn(User $user) => $user->isAdmin());
Option 3 — Custom middleware
Any middleware alias your host registers will work. Examples:
# Filament panel auth
QWIKBLOG_ADMIN_MIDDLEWARE=panel.auth
# Multiple middlewares stacked
QWIKBLOG_ADMIN_MIDDLEWARE=auth,verified,role:editor
# Fully custom
QWIKBLOG_ADMIN_MIDDLEWARE=my-blog-gate
| Route | Description |
|---|---|
/admin/login |
Login form (used only with default admin middleware) |
/admin → /admin/posts |
Posts index (Livewire — search, filters, pagination, polls every 30s) |
/admin/posts/create |
New post |
/admin/posts/{slug}/edit |
Edit post |
/admin/posts/{slug}/images |
Manage images (Livewire) |
The package's AdminAuth middleware (alias admin) uses session auth, file-based by default (SESSION_DRIVER=file), so no database is required.
The admin posts index has four filters that combine with AND:
| Filter | What it matches |
|---|---|
| Search | Title, subtitle, summary, author. Debounced 300ms |
| Category | Single category — drop-down of every category across all posts |
| Tag | Single tag |
| Status | All / Published / Scheduled |
Filter state is preserved in the URL — /admin/posts?status=scheduled&category=Palos
is bookmarkable and reload-safe. The header shows totals
(12 published · 3 scheduled · 15 total); the Status dropdown shows
the same counts inline so editors can find scheduled posts without first
applying a filter.
Page paginates at 30 by default — useful when scheduling content months
ahead. Override via QWIKBLOG_ADMIN_PER_PAGE.
Scheduled rows show their relative publish time underneath the date
("in 3 weeks"), updating on the 30-second wire:poll.
For migrating content or seeding test data:
php artisan blog:import path/to/manifest.php
The manifest is a .php file returning an array (recommended for long
bodies — heredoc is friendlier than JSON-escaping) or .json.
<?php
return [
[
'title' => 'My Imported Post',
'date' => '2025-01-15',
'subtitle' => '...',
'summary' => '...',
'categories' => ['News'],
'tags' => ['launch'],
'author' => 'Jane Editor',
'hero_image_url' => 'https://example.com/photo.jpg',
'gallery_image_urls' => [
'https://example.com/photo-2.jpg',
],
'body' => <<<'MD'
# Markdown body here
MD,
],
];
Image URLs (when present) are downloaded into public/images/blog/{slug}/
at import time. Failures are non-fatal — posts are still created.
| Flag | Effect |
|---|---|
--dry-run |
Preview without writing |
--skip-images |
Create posts, skip downloads |
--overwrite |
Replace any existing post with a matching slug |
The package ships a sample manifest (12 demo flamenco posts across 4 categories, with images) you can use to populate a fresh install:
php artisan blog:examples flamenco
The command reads the bundled manifest directly from the package and
downloads the referenced images. You only need to publish the seeds
(vendor:publish --tag=qwikblog-seeds) if you want to edit the manifest
before importing.
To remove the demo content later:
rm resources/posts/*.md
rm -rf public/images/blog/
php artisan blog:refresh
To customise any of the package's public-facing views (post index, single post, RSS feed, sitemap), publish them into your host app:
php artisan vendor:publish --tag=qwikblog-views
This copies every Blade file from the package into
resources/views/vendor/qwikblog/ in your project. The package's own
copies stay untouched; Laravel's view loader gives priority to whatever
sits in resources/views/vendor/qwikblog/, so anything you edit there
overrides the default.
The two views you'll most likely want to customise:
| File | Purpose |
|---|---|
resources/views/vendor/qwikblog/blog/index.blade.php |
Post listing page |
resources/views/vendor/qwikblog/blog/show.blade.php |
Single post page |
Both extend the layout named by QWIKBLOG_LAYOUT (default app). Article
body styling uses the Tailwind Typography plugin
— the prose class on <article> is the source of truth.
To revert to the package defaults later, just delete the file from
resources/views/vendor/qwikblog/ — the package's own version takes
over again automatically.
The admin views can be customised the same way (they're published by the same command), but most sites won't need to — the admin is meant to be functional rather than branded.
All package configuration lives in config/qwikblog.php and reads from
.env overrides. Every value is optional except admin credentials.
| Env var | Default | What it does |
|---|---|---|
ADMIN_USERNAME |
— | Required. Admin login |
ADMIN_PASSWORD |
— | Required. Admin login |
QWIKBLOG_POSTS_PATH |
resources/posts |
Where the .md post files live |
QWIKBLOG_CACHE_DURATION |
3600 |
Post cache TTL in seconds |
QWIKBLOG_LANGUAGE |
app locale | RSS feed <language> tag |
QWIKBLOG_READING_WPM |
200 |
Words per minute for "X min read" |
QWIKBLOG_PER_PAGE |
12 |
Posts per page on public blog |
QWIKBLOG_ADMIN_PER_PAGE |
30 |
Posts per page in admin table |
QWIKBLOG_FEED_LIMIT |
50 |
Max items in RSS feed |
QWIKBLOG_ADMIN_PATH |
admin |
Admin URL prefix |
QWIKBLOG_ADMIN_MIDDLEWARE |
admin |
Middleware that gates the admin (set to auth to use Laravel's auth) |
QWIKBLOG_ADMIN_LOGOUT_ROUTE |
admin.logout |
Route name the admin's logout button posts to |
QWIKBLOG_LAYOUT |
app |
Layout name the public blog views extend |
QWIKBLOG_TAXONOMY_URL_STYLE |
flat |
flat (/blog/{slug}) or prefixed (/blog/category/{slug}) |
QWIKBLOG_RELATED_TAG_WEIGHT |
2 |
Related-post tag weight |
QWIKBLOG_RELATED_CATEGORY_WEIGHT |
1 |
Related-post category weight |
QWIKBLOG_RELATED_LIMIT |
3 |
Number of related posts shown |
After editing .env:
php artisan optimize:clear
Clears config, route, view and compiled caches in one go. Required if
you've previously run php artisan config:cache or are deployed in a way
that caches config.
To verify a value is being read:
php artisan tinker --execute="dump(config('qwikblog.per_page'));"
If the value comes back as the default when you've set the env var,
either config/qwikblog.php isn't at the project root, the env var is
mistyped (case-sensitive), or config is still cached.
Posts are cached for an hour. To clear manually:
php artisan blog:refresh
The admin clears this cache automatically on every create/update/delete,
and blog:import clears it after a bulk import.
# Dev
npm run dev
php artisan serve
# After editing post files directly
php artisan blog:refresh
# After dropping in new components, commands or config
php artisan view:clear
php artisan optimize:clear
# Production build
npm run build
| Symptom | Fix |
|---|---|
| Stale post data after editing files directly | php artisan blog:refresh |
500 with "undefined property" or "undefined method" on BlogPost |
Cached BlogPost doesn't match the current class shape — php artisan blog:refresh |
| Admin pages 500 with "Unable to locate component" | Stale view cache. php artisan view:clear |
| WYSIWYG editor doesn't load | Browser console says "Toast UI Editor not found on window" — run npm install @toast-ui/editor && npm run dev. Verify resources/js/qwikblog-admin.js exists (publish it via vendor:publish --tag=qwikblog-admin-js if not) and is in your vite.config.js inputs |
/admin shows "Admin credentials are not configured" |
Set both ADMIN_USERNAME and ADMIN_PASSWORD, then php artisan optimize:clear |
QWIKBLOG_* env var seems ignored |
php artisan optimize:clear. Verify with php artisan tinker --execute="dump(config('qwikblog.per_page'));" |
| Image upload "browse and nothing happens" | Duplicate Alpine load. Make sure no <script src=".../alpine..."> is in the admin layout — Livewire bundles its own |
| Path | Role |
|---|---|
app/ValueObjects/BlogPost.php |
Front-matter parsing, YAML serialisation, post object |
app/Services/BlogService.php |
Post CRUD, caching, taxonomy/archive/search/related helpers |
app/Services/BlogImageService.php |
Per-post image directory management |
app/Support/BlogUrls.php |
Taxonomy URL helper (flat/prefixed style) |
app/Http/Controllers/BlogController.php |
All public routes |
app/Http/Controllers/AdminController.php |
Admin auth + post CRUD |
app/Http/Middleware/AdminAuth.php |
Session-based admin guard |
app/Livewire/Admin/PostsIndex.php |
Live-polling admin posts table with filters |
app/Livewire/Admin/BlogImages.php |
Image gallery component |
app/Console/Commands/ImportPosts.php |
blog:import |
app/Console/Commands/InstallExamples.php |
blog:examples |
app/Console/Commands/RefreshBlog.php |
blog:refresh |
config/qwikblog.php |
Package configuration |
resources/posts/ |
Markdown post files |
resources/seeds/ |
Bundled example post manifests |
resources/views/blog/ |
Public-facing views |
resources/views/admin/ |
Admin views |
PHP / Composer:
laravel/framework ^12livewire/livewire ^3.7Node / npm:
@toast-ui/editor — admin body editor (Vite-bundled)@tailwindcss/typography — prose styling on the show pagealpinejs — public-facing carouseltailwindcss ^4 with @tailwindcss/viteNode / npm (in your host app):
@toast-ui/editor — admin body editor (Vite-bundled via the published qwikblog-admin.js)@tailwindcss/typography — prose styling on the show pagealpinejs — public-facing carouseltailwindcss ^4 with @tailwindcss/viteCDN-loaded by the admin chrome only:
Alpine in the admin is provided by Livewire's bundled copy — do not load it separately.
How can I help you explore Laravel packages today?