Skip to content

Security and SEO in our own blog engine: what we built and what it gave us

How we protect the blog from attacks and make it easy for search engines to read: validation, HTML sanitizing, security headers, structured data, sitemap and feeds. With code and the results after 12 months.

AuthorIT Department
  • 9 min read
  • 208 views
Security and SEO in our own blog engine: what we built and what it gave us

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

  1. Architecture sets the limits. A solid foundation saves months of work later.

  2. Automation frees time for the content itself.

  3. Speed is a feature: fast sites convert better.

  4. Good UX solves business problems, and convenience shows up in the metrics.

  5. 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

Tags

Share this article

Back to blog

Cookies

We use essential cookies and one functional cookie, the latter only with your consent. Visit statistics are counted on our server, without cookies. Cookie Policy