The blog you are reading took one evening: three tables, two pieces of reader behaviour and some structured data. No packages - not out of principle, but because a ready-made blog drags in categories, comments and an admin panel, of which I needed about a fifth.

Below is the whole design and five places where I stepped on a rake. The rakes are more interesting than the design, so if you have built blogs before, skip straight to them.

One table for posts, two for reader behaviour

A post is field pairs for two languages (title, excerpt, body, meta), a slug, a cover, a published flag, a date and soft deletes. Plus two JSON columns: tags and FAQ entries.

Separately there are a views table and a votes table. Neither is wired to the model through relations on purpose: they are written to often and quickly, and read from almost never.

Publishing: one scope for two states

A post is visible when the draft flag is off and the publication date has arrived:

public function scopePublished($query)
{
    return $query->where('status', true)
        ->where(function ($q) {
            $q->whereNull('published_at')->orWhere('published_at', '<=', now());
        });
}

Scheduled publishing falls out of this for free: set a future date and the post appears on its own. No cron job needed - the condition is evaluated per request. Handy when you write a batch of posts in one evening but want to release them weekly.

Views: a denormalised counter

Running COUNT(*) over the views table on every render is a bad idea: a list of nine posts becomes nine aggregate queries. So the counter lives on the post itself, and the table exists only to stop one reader inflating it by refreshing:

public function recordView(string $ip): void
{
    $seen = DB::table('blog_post_views')
        ->where('post_id', $this->id)
        ->where('ip', $ip)
        ->where('created_at', '>=', now()->subDay())
        ->exists();

    if ($seen) {
        return;
    }

    DB::table('blog_post_views')->insert([...]);
    $this->increment('views');
}

A day is an arbitrary window but a sensible one: coming back to finish reading in the evening counts, hitting refresh five times does not.

Votes: one per reader, with room to change your mind

Two buttons under the post, yes and no. A vote is keyed to a hash of the address rather than the address itself: counting only needs to tell voters apart, and storing other people's IPs without a reason is not appealing.

DB::table('blog_post_votes')->updateOrInsert(
    ['post_id' => $post->id, 'ip_hash' => hash('sha256', $request->ip())],
    ['vote' => $vote, 'created_at' => now()]
);

A unique index on the pair plus updateOrInsert gives the behaviour you want: a second click changes the previous vote rather than adding another. The protection is honest but not a fortress - changing address defeats it. For "was this useful" that is enough; for a vote with prizes it is not.

Structured data and a credibility threshold

The page emits Article with publication and modification dates, and when the FAQ is filled, a FAQPage as well. The third block is the interesting one - the rating:

if ($voteTotal >= 3) {
    $articleLd['aggregateRating'] = [
        '@type' => 'AggregateRating',
        'ratingValue' => (string) round(1 + ($voteYes / $voteTotal) * 4, 1),
        'ratingCount' => (string) $voteTotal,
    ];
}

The three-vote threshold is not arbitrary. A single thumbs up becomes "rating 5.0 based on one vote" - technically true, effectively noise that a search engine is entitled to read as manipulation. The share of positive votes maps onto a five point scale linearly: all yes is five, all no is one.

Five rakes

Slug generation must account for the trash. The generator looks for taken slugs using withTrashed(). Otherwise: delete a post, create a new one with the same title, restore the first - and you have two posts on one address against a unique index.

Route order matters more than it looks. The /blog/trashed and /blog/create routes must be declared before /blog/{blog}, or model binding takes the words "trashed" and "create" for a slug and returns a 404. What makes it annoying is that it looks like a missing page rather than a routing problem.

The validation call does not return every field. A classic, and it cost me time:

$data = $request->validate([...]);
$slug = BlogPost::makeSlug($data['slug'] ?: $data['title']); // undefined array key

The call returns only the keys that came in the request. Leave an optional field empty and the key is simply absent. Caught by a test that creates a post without a slug, not by reading the code.

The denormalised counter fires in an unexpected place. When I attached a handler that notifies search engines about post changes, it turned out increment() in Eloquent raises the save events. Every reader visit would have become a call to three external services. The fix is checking what actually changed, but foreseeing the problem is hard - it lives at the seam between two harmless decisions.

Sorting by rating needs a neutral value. The admin list sorts by share of positive votes. Posts with no votes must not be treated as zero, or a brand new post lines up next to one everybody disliked. So the subquery returns minus one:

COALESCE(SUM(vote) * 100.0 / NULLIF(COUNT(*), 0), -1)

Tags and FAQ without extra tables

Both live in JSON columns on the post. A separate tags table would be needed for "all posts under this tag" pages, and there are none, nor plans for any.

Everything is entered as text: tags comma separated, FAQ as "question, pipe, answer" per line. The controller parses the strings into structures. That turned out faster than any interface with "add row" buttons - both to build and to use.

Tags take an optional English label after the same pipe: Локализация | i18n. Without it the English version would show the Russian label - which I discovered only after publishing the first few posts.

What the blog does not have

No comments, because moderating spam would cost more than comments on a technical blog are worth. No categories: three posts do not need a taxonomy. No full text search, for the same reason.

All of it can be added in an evening when it is actually needed. The reverse trip - carving the unneeded parts out of a ready-made package - usually takes longer.