This is the last article in the series, and it covers two things every web project needs: security and search engine optimization. Here is how we protected the blog from attacks and, at the same time, made it easy for search engines to read.
Security in layers
No single tool makes a site secure. It takes several checks, one behind the other. We start with the basics.
Validation of all input
The first line of defense is checking what comes in. We do it in Laravel Form Request classes:
class StorePostRequest extends FormRequest
{
public function authorize()
{
return $this->user()->can('create', Post::class);
}
public function rules()
{
$rules = [
'author_id' => ['required', 'integer', 'exists:admin_blog_labels,id'],
'preview_image' => ['required', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
'scheduled_at' => ['nullable', 'date', 'after:now'],
'categories' => ['required', 'array', 'min:1'],
'categories.*' => ['integer', 'exists:admin_blog_labels,id'],
];
// Multilingual content validation
foreach(Country::$langs as $lang) {
$rules["contents.{$lang}.title"] = [
'required',
'string',
'min:3',
'max:255',
new NoScriptTags() // Custom rule
];
$rules["contents.{$lang}.slug"] = [
'required',
'string',
'max:255',
Rule::unique('admin_blog_posts_contents', 'slug'),
'regex:/^[a-zA-Z0-9\-_]+$/' // Only safe characters
];
$rules["contents.{$lang}.content_html"] = [
'required',
'string',
'min:50',
new SafeHtml() // XSS check
];
}
return $rules;
}
public function messages()
{
return [
'preview_image.max' => 'The image size must not exceed 2MB.',
'contents.*.slug.regex' => 'The slug may only contain letters, numbers, dashes and underscores.',
];
}
}
For HTML we wrote our own rule:
class SafeHtml implements Rule
{
protected array $dangerousTags = [
'script', 'iframe', 'object', 'embed', 'form',
'input', 'button', 'select', 'textarea'
];
protected array $dangerousAttributes = [
'onclick', 'onmouseover', 'onerror', 'onload',
'javascript:', 'vbscript:', 'data:text/html'
];
public function passes($attribute, $value)
{
// Check for dangerous tags
foreach ($this->dangerousTags as $tag) {
if (stripos($value, "<{$tag}") !== false) {
return false;
}
}
// Check for dangerous attributes
foreach ($this->dangerousAttributes as $attr) {
if (stripos($value, $attr) !== false) {
return false;
}
}
return true;
}
public function message()
{
return 'The :attribute contains potentially unsafe HTML content.';
}
}
HTML sanitizing
Validation is not the end of it. We also clean the HTML before saving:
use HTMLPurifier;
use HTMLPurifier_Config;
class ContentSanitizer
{
protected HTMLPurifier $purifier;
public function __construct()
{
$config = HTMLPurifier_Config::createDefault();
// Allowed tags
$config->set('HTML.Allowed',
'p,br,strong,em,u,i,strike,sub,sup,
h1,h2,h3,h4,h5,h6,
ul,ol,li,
blockquote,pre,code,
a[href|title|target],
img[src|alt|width|height],
table,thead,tbody,tr,td,th'
);
// Safe links
$config->set('HTML.TargetBlank', true);
$config->set('HTML.Nofollow', true);
// XSS protection
$config->set('Core.EscapeNonASCIICharacters', true);
$config->set('Output.FlashCompat', true);
$this->purifier = new HTMLPurifier($config);
}
public function sanitize(string $html): string
{
return $this->purifier->purify($html);
}
public function sanitizeForEmail(string $html): string
{
// Stricter rules for email
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,br,strong,em,a[href]');
$config->set('HTML.TidyLevel', 'heavy');
$emailPurifier = new HTMLPurifier($config);
return $emailPurifier->purify($html);
}
}
CSRF and XSS
Laravel protects against both out of the box. We added security headers on top:
// Middleware for additional security headers
class SecurityHeaders
{
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy
$csp = "default-src 'self'; " .
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " .
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " .
"font-src 'self' https://fonts.gstatic.com; " .
"img-src 'self' data: https:; " .
"connect-src 'self' https://api.openai.com";
$response->headers->set('Content-Security-Policy', $csp);
return $response;
}
}
Authorization and policies
Access rights live in Laravel Policies, one rule set per role:
class PostPolicy
{
public function viewAny(User $user): bool
{
return $user->hasAnyRole(['admin', 'moderator', 'author']);
}
public function view(User $user, Post $post): bool
{
// Admins see everything
if ($user->hasRole('admin')) {
return true;
}
// Authors see only their posts
if ($user->hasRole('author')) {
return $post->creator_id === $user->id;
}
// Moderators see published and pending moderation
if ($user->hasRole('moderator')) {
return $post->published_at !== null || $post->drafted_at !== null;
}
return false;
}
public function create(User $user): bool
{
return $user->hasAnyRole(['admin', 'author']);
}
public function update(User $user, Post $post): bool
{
if ($user->hasRole('admin')) {
return true;
}
if ($user->hasRole('author')) {
return $post->creator_id === $user->id &&
$post->published_at === null;
}
return false;
}
public function delete(User $user, Post $post): bool
{
return $user->hasRole('admin') ||
($user->hasRole('author') &&
$post->creator_id === $user->id &&
$post->published_at === null);
}
}
SEO: making the content readable for search engines
Now SEO. The goal is simple: a search engine should understand what each page is about without guessing.
Structured Data (Schema.org)
We add micro-markup so that search engines can read the article's data directly:
class SchemaGenerator
{
public function generateArticleSchema(Post $post, Content $content): array
{
return [
"@context" => "https://schema.org",
"@type" => "Article",
"headline" => $content->title,
"description" => $content->excerpt,
"image" => $post->image_full_absolute_path,
"datePublished" => $post->published_at->toIso8601String(),
"dateModified" => $post->updated_at->toIso8601String(),
"author" => [
"@type" => "Person",
"name" => $post->author->name,
"url" => route('blog.author', $post->author->id)
],
"publisher" => [
"@type" => "Organization",
"name" => "IT Compass",
"logo" => [
"@type" => "ImageObject",
"url" => asset('images/logo.png')
]
],
"mainEntityOfPage" => [
"@type" => "WebPage",
"@id" => route('blog.show', [$content->language, $content->slug])
],
"keywords" => implode(', ', $content->keywords),
"articleSection" => $post->categories->pluck('name')->implode(', '),
"wordCount" => str_word_count(strip_tags($content->content_html))
];
}
}
In the template:
<script type="application/ld+json">
{!! json_encode($schemaData, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
</script>
Meta tags
One component sets all the meta tags on a page:
<template>
<Head>
<title>{{ metaTitle }}</title>
<meta name="description" :content="metaDescription" />
<meta name="keywords" :content="metaKeywords" />
<!-- Open Graph -->
<meta property="og:type" content="article" />
<meta property="og:title" :content="ogTitle" />
<meta property="og:description" :content="ogDescription" />
<meta property="og:image" :content="ogImage" />
<meta property="og:url" :content="canonicalUrl" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" :content="twitterTitle" />
<meta name="twitter:description" :content="twitterDescription" />
<meta name="twitter:image" :content="twitterImage" />
<!-- Canonical URL -->
<link rel="canonical" :href="canonicalUrl" />
<!-- Alternate languages -->
<link
v-for="lang in alternateLanguages"
:key="lang.code"
rel="alternate"
:hreflang="lang.code"
:href="lang.url"
/>
</Head>
</template>
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
const props = defineProps<{
post: Post;
content: Content;
currentLanguage: string;
}>();
const metaTitle = computed(() => {
const siteName = 'IT Compass Blog';
return props.content.meta_title
? `${props.content.meta_title} | ${siteName}`
: `${props.content.title} | ${siteName}`;
});
const metaDescription = computed(() => {
return props.content.meta_description ||
props.content.excerpt.substring(0, 160);
});
const canonicalUrl = computed(() => {
return route('blog.show', [
props.currentLanguage,
props.content.slug
]);
});
const alternateLanguages = computed(() => {
return props.post.contents.map(content => ({
code: content.language,
url: route('blog.show', [content.language, content.slug])
}));
});
</script>
URL structure
URLs are readable and carry the language:
// routes/web.php
Route::prefix('blog')->group(function () {
// Posts list with language
Route::get('/{language}', [BlogController::class, 'index'])
->where('language', 'en|pl|uk')
->name('blog.index');
// Post view
Route::get('/{language}/{slug}', [BlogController::class, 'show'])
->where('language', 'en|pl|uk')
->name('blog.show');
// Categories
Route::get('/{language}/category/{category}', [BlogController::class, 'category'])
->where('language', 'en|pl|uk')
->name('blog.category');
// Author
Route::get('/author/{author}', [BlogController::class, 'author'])
->name('blog.author');
});
// Automatic redirect to user language
Route::get('/blog', function () {
$userLang = request()->getPreferredLanguage(['en', 'pl', 'uk']);
return redirect()->route('blog.index', ['language' => $userLang ?? 'en']);
});
XML sitemap
The sitemap is generated on request:
class SitemapController extends Controller
{
public function index()
{
$sitemap = Sitemap::create();
// Main pages
$sitemap->add(Url::create('/')->setPriority(1.0));
$sitemap->add(Url::create('/blog')->setPriority(0.9));
// Posts for each language
$posts = Post::published()->with('contents')->get();
foreach ($posts as $post) {
foreach ($post->contents as $content) {
$url = Url::create(route('blog.show', [
$content->language,
$content->slug
]))
->setLastModificationDate($post->updated_at)
->setChangeFrequency(Url::CHANGE_FREQUENCY_WEEKLY)
->setPriority(0.8);
// Add alternate languages
foreach ($post->contents as $altContent) {
if ($altContent->language !== $content->language) {
$url->addAlternate(
route('blog.show', [$altContent->language, $altContent->slug]),
$altContent->language
);
}
}
$sitemap->add($url);
}
}
// Categories
$categories = Label::categories()->withCount('posts')->get();
foreach ($categories as $category) {
if ($category->posts_count > 0) {
foreach (['en', 'pl', 'uk'] as $lang) {
$sitemap->add(
Url::create(route('blog.category', [$lang, $category->id]))
->setPriority(0.6)
);
}
}
}
return $sitemap;
}
}
RSS/Atom Feeds
The feed runs on the spatie/laravel-feed package:
// config/feed.php
return [
'feeds' => [
'main' => [
'items' => [Post::class, 'getFeedItems'],
'url' => '/blog/feed',
'title' => 'IT Compass Blog',
'description' => 'Latest articles from IT Compass',
'language' => 'en-US',
'view' => 'feed::atom',
'type' => 'application/atom+xml',
],
],
];
// In Post model
public function toFeedItem(): FeedItem
{
$content = $this->localContent;
return FeedItem::create([
'id' => $this->id,
'title' => $content->title,
'summary' => $content->excerpt,
'updated' => $this->published_at ?? $this->created_at,
'link' => url("/blog/{$content->language}/{$content->slug}"),
'authorName' => $this->author->name,
'category' => $this->categories->pluck('name')->toArray(),
]);
}
public static function getFeedItems()
{
return Cache::remember('feed_items', now()->addMinutes(10), function () {
return self::published()
->with(['author', 'categories'])
->latest('published_at')
->take(20)
->get();
});
}
Subscriptions
Email subscription with a spam check:
class SubscriberController extends Controller
{
public function store(StoreSubscriberRequest $request)
{
// Spam check
if ($this->isSpamRequest($request)) {
return response()->json(['message' => 'Thank you!'], 200);
}
DB::transaction(function () use ($request) {
$subscriber = Subscriber::create([
'email' => $request->validated()['email'],
'ip_address' => $request->ip(),
'language' => app()->getLocale(),
'confirmed' => false,
'token' => Str::random(32),
]);
// Send confirmation
Mail::to($subscriber->email)->queue(
new ConfirmSubscriptionMail($subscriber)
);
});
return response()->json([
'message' => __('blog.subscription_confirm_sent')
]);
}
protected function isSpamRequest($request): bool
{
// Honeypot field check
if ($request->filled('website')) {
return true;
}
// IP request frequency check
$recentAttempts = Subscriber::where('ip_address', $request->ip())
->where('created_at', '>', now()->subMinutes(5))
->count();
return $recentAttempts >= 3;
}
}
Security monitoring
Suspicious requests are logged, and critical ones also trigger an email:
class SecurityMonitor
{
public static function logSuspiciousActivity(string $type, array $data)
{
DB::table('security_logs')->insert([
'type' => $type,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
'user_id' => auth()->id(),
'data' => json_encode($data),
'created_at' => now(),
]);
// Alert on critical events
if (in_array($type, ['sql_injection_attempt', 'xss_attempt'])) {
Mail::to(config('security.alert_email'))->queue(
new SecurityAlertMail($type, $data)
);
}
}
}
Results
What we saw after putting all of this in place:
No successful attacks in 12 months of operation
Organic traffic up 280% over 6 months
Average position in search results: 12, down from 47
New posts get indexed in under 24 hours
Core Web Vitals all in the green
Console commands
# Security scanning
php artisan security:scan
# SEO checking
php artisan seo:analyze
# Performance monitoring
php artisan monitor:performance
What the series comes down to
Architecture sets the limits. A solid foundation saves months of work later.
Automation frees time for the content itself.
Speed is a feature: fast sites convert better.
Good UX solves business problems, and convenience shows up in the metrics.
Security and SEO are cheapest when built in from the start.
Was a custom CMS worth it?
For us, yes. It was hard work, but we ended up with a system that:
does exactly what we need
grows with the business
gives us an edge over competitors
shows what we can do technically
I hope the series was useful. The best code is the code that solves a real business problem.
Thanks for reading the whole series. Questions are welcome in the comments.
CEO IT Compass



