Add a blog to Laravel
Build a Laravel blog with published delivery, Markdown views, draft publishing, cursor pagination, caching, sitemap, and RSS.
Laravel's HTTP client, cache, validation, policies, and Blade templates provide everything needed to add a Cli Blog-powered publication without a custom CMS data layer.
What you will build
- A cached
/blogindex of published posts. - A slug route that renders safe Markdown and SEO metadata.
- Cursor-based load-more links.
- An authorized controller action that creates drafts.
- Public
/sitemap.xmland/feed.xmlroutes.
Prerequisites
Start with a Laravel application that can make outbound HTTPS requests. You also need public and private keys from a Cli Blog organization and at least one published post.
Configure the environment
Add placeholders to .env.example and real values only to your deployment environment:
CLI_BLOG_PUBLIC_KEY=<public-api-key>
CLI_BLOG_API_KEY=<private-api-key>
CLI_BLOG_BASE_URL=https://api.cli-blog.comAdd a service entry in config/services.php:
'cli_blog' => [
'base_url' => env('CLI_BLOG_BASE_URL', 'https://api.cli-blog.com'),
'public_key' => env('CLI_BLOG_PUBLIC_KEY'),
'private_key' => env('CLI_BLOG_API_KEY'),
],Do not expose either key with a VITE_ prefix. A public key can read published content, but keeping it server-side makes key rotation and caching predictable. A private key must never reach a browser bundle, HTML response, log, or committed file.
Create a Cli Blog service
Create app/Services/CliBlog.php:
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class CliBlog
{
private function client(string $key): PendingRequest
{
return Http::baseUrl(config('services.cli_blog.base_url'))
->withHeaders(['x-api-key' => $key])
->acceptJson()
->timeout(10)
->retry(2, 250, throw: false);
}
public function public(): PendingRequest
{
return $this->client(config('services.cli_blog.public_key'));
}
public function private(): PendingRequest
{
return $this->client(config('services.cli_blog.private_key'));
}
}Keep the response status visible to controllers by calling throw() after you decide how the route should handle upstream errors.
List published posts
Create a controller action:
use App\Services\CliBlog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
public function index(Request $request, CliBlog $blog)
{
$after = $request->string('after')->toString();
$cacheKey = 'blog:index:en-US:' . hash('sha256', $after);
$result = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($blog, $after) {
return $blog->public()->get('/v1/posts', array_filter([
'status' => 'published',
'locale' => 'en-US',
'fields' => 'summary,seo',
'include' => 'authors,tags',
'limit' => 20,
'after' => $after ?: null,
]))->throw()->json();
});
return view('blog.index', ['result' => $result]);
}Use summary and seo on index pages. Includes embed author and tag objects so the template does not need additional API requests.
Add the cursor link
In Blade, show a load-more link only when another cursor exists:
@foreach ($result['data'] as $post)
<article>
<h2><a href="{{ route('blog.show', $post['slug']) }}">{{ $post['title'] }}</a></h2>
<p>{{ $post['excerpt'] ?? '' }}</p>
</article>
@endforeach
@if ($result['has_more'] && $result['next_cursor'])
<a href="{{ route('blog.index', ['after' => $result['next_cursor']]) }}">More posts</a>
@endifTreat next_cursor as opaque. Send it back as after with the same locale, filters, and sort. The maximum limit is 100. Cursor mode is faster than numbered pages because it does not calculate exact totals.
Retrieve a post by slug
public function show(string $slug, CliBlog $blog)
{
$post = Cache::remember("blog:post:en-US:{$slug}", now()->addMinutes(5), fn () =>
$blog->public()->get("/v1/posts/{$slug}", [
'locale' => 'en-US',
'fields' => 'summary,content,seo',
'include' => 'authors,categories,tags,media',
])->throw()->json()
);
return view('blog.show', ['post' => $post]);
}Request content only for a detail page. Slug lookup is locale-aware, so include the locale consistently.
Render Markdown safely
Laravel's Str::markdown uses CommonMark. Strip embedded HTML and disable unsafe links:
@php
$html = Str::markdown($post['body_markdown'], [
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
@endphp
<article>{!! $html !!}</article>Only use Blade's unescaped output for the sanitized Markdown result. Continue using {{ }} for titles, descriptions, bylines, and all other plain text.
Add SEO metadata
<title>{{ $post['seo_title'] ?? $post['title'] }}</title>
<meta name="description" content="{{ $post['seo_description'] ?? $post['excerpt'] ?? '' }}">
@if (!empty($post['canonical_url']))
<link rel="canonical" href="{{ $post['canonical_url'] }}">
@endifUse the seo field group for canonical, robots, Open Graph, and schema fields. Use included media when an image is configured.
Create drafts from an authorized action
Validate input and enforce an application policy before using the private key:
use Illuminate\Support\Facades\Gate;
public function store(Request $request, CliBlog $blog)
{
Gate::authorize('create-blog-post');
$input = $request->validate([
'title' => ['required', 'string', 'max:200'],
'markdown' => ['required', 'string', 'max:200000'],
]);
$draft = $blog->private()->post('/v1/posts', [
'title' => $input['title'],
'body_markdown' => $input['markdown'],
'locale' => 'en-US',
'status' => 'draft',
])->throw()->json();
return response()->json(['id' => $draft['id'], 'status' => $draft['status']], 201);
}Define create-blog-post with your application's existing authorization rules. Create drafts by default. Publishing should be a separate, reviewed action rather than a client-controlled status field.
Serve sitemap and feed XML
Register routes that fetch GET https://api.cli-blog.com/v1/sitemap?locale=en-US&limit=100 and GET https://api.cli-blog.com/v1/feed?locale=en-US&limit=20 with the public key. Return the upstream body with application/xml for the sitemap and application/rss+xml for the feed. Cache both responses and serve them from the same host as the blog.
Handle failures
Map an upstream 404 to Laravel's abort(404). A 401 indicates an invalid or missing key; 403 indicates the wrong key type or permission; 429 should be retried later. Log the route, response status, and a request identifier if available, but never headers containing keys.
Production checklist
- Store keys in encrypted deployment configuration and run
config:cacheafter setting them. - Protect private routes with authentication, policies, CSRF protection, validation, and rate limits.
- Cache published pages and purge their cache after your publish workflow.
- Use field groups deliberately and keep index responses lean.
- Preserve cursor filters and test a multi-page result set.
- Strip raw HTML from Markdown and escape metadata in Blade.
- Verify canonical, sitemap, feed, empty, not-found, and rate-limit behavior.