I've had Disqus on this blog for years. It works, more or less. But every time I open a post I see ads I didn't ask for, a tracker loading that I didn't choose, and a dependency on a third party that could disappear or change its terms tomorrow. Also, adding a new comment category or anything requires going through their dashboard. It's just annoying.

So I removed it. Here's exactly how I built the replacement.

What I wanted

  • Guests can comment with a name and optional email (for Gravatar)
  • Optional "Sign in with GitHub" to pre-fill the form
  • Infinite nested replies
  • I can comment too, linked to my admin account, no moderation needed
  • Email notification when someone comments
  • Honeypot spam protection (no captcha, no friction)
  • A moderation queue in Filament
  • All existing Disqus comments migrated over

The Comment model

Schema::create('comments', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->unsignedInteger('post_id');
    $table->unsignedBigInteger('parent_id')->nullable(); // infinite nesting
    $table->unsignedInteger('user_id')->nullable();      // me, when I comment
    $table->string('author_name')->nullable();
    $table->string('author_email')->nullable();
    $table->string('github_username')->nullable();
    $table->string('github_avatar')->nullable();
    $table->text('body');
    $table->boolean('is_approved')->default(false);
    $table->timestamps();
});

parent_id is a self-referential foreign key. The user_id links to my admin user, when it's set, the comment is auto-approved and shows my avatar.

The model has a few useful methods:

public function displayName(): string
{
    if ($this->user_id && $this->user) {
        return $this->user->name;
    }
    return $this->author_name ?? 'Anonymous';
}

public function avatarUrl(): string
{
    if ($this->github_avatar) {
        return $this->github_avatar;
    }
    if ($this->user_id && $this->user) {
        return 'https://www.gravatar.com/avatar/'.md5(strtolower($this->user->email)).'?s=80&d=mp';
    }
    if ($this->author_email) {
        return 'https://www.gravatar.com/avatar/'.md5(strtolower($this->author_email)).'?s=80&d=mp';
    }
    return 'https://www.gravatar.com/avatar/'.md5($this->author_name ?? 'guest').'?s=80&d=mp&f=y';
}

The controller

public function store(Request $request, Post $post)
{
    $isOwner = auth()->check();

    $validated = $request->validate([
        'body' => ['required', 'string', 'max:5000'],
        'parent_id' => ['nullable', 'integer', 'exists:comments,id'],
        ...($isOwner ? [] : [
            'author_name' => ['required', 'string', 'max:100'],
            'author_email' => ['nullable', 'email', 'max:255'],
        ]),
    ]);

    $githubData = session('github_user');

    $comment = $post->allComments()->create([
        'parent_id' => $validated['parent_id'] ?? null,
        'user_id' => $isOwner ? auth()->id() : null,
        'author_name' => $isOwner ? null : $validated['author_name'],
        'author_email' => $isOwner ? null : ($validated['author_email'] ?? null),
        'github_username' => $isOwner ? null : ($githubData['username'] ?? null),
        'github_avatar' => $isOwner ? null : ($githubData['avatar'] ?? null),
        'body' => $validated['body'],
        'is_approved' => $isOwner,
    ]);

    if (! $isOwner) {
        User::where('superuser', true)->first()?->notify(new NewCommentPosted($comment, $post));
    }

    return back()->with('comment_posted', $isOwner
        ? 'Comment posted!'
        : 'Comment submitted! It will appear after moderation.');
}

The route uses the ProtectAgainstSpam middleware from spatie/laravel-honeypot, no captcha, just a hidden field that bots fill in and humans don't:

Route::post('post/{post}/comments', 'CommentController@store')
    ->name('comments.store')
    ->middleware(\Spatie\Honeypot\ProtectAgainstSpam::class);

Infinite nesting in Blade

The trick here is a recursive component. I load all approved comments for a post in one query and build the tree in memory in the controller:

$allComments = $post->allComments()
    ->where('is_approved', true)
    ->with('user')
    ->orderBy('created_at')
    ->get()
    ->keyBy('id');

$allComments->each(function ($comment) use ($allComments) {
    $comment->setRelation('children', $allComments->filter(
        fn ($c) => $c->parent_id === $comment->id
    )->values());
});

$comments = $allComments->filter(fn ($c) => $c->parent_id === null)->values();

One query, no N+1, regardless of nesting depth.

The component is a simple recursive include:

{{-- resources/views/components/comment-thread.blade.php --}}
@props(['comments', 'post', 'depth' => 0])

<div class="{{ $depth > 0 ? 'ml-8 border-l-2 border-rule pl-4' : '' }} space-y-4">
    @foreach ($comments as $comment)
    <div id="comment-{{ $comment->id }}">
        <div class="flex gap-3 items-start">
            <img src="{{ $comment->avatarUrl() }}" class="size-8 rounded-full shrink-0">
            <div class="flex-1">
                <span class="font-semibold text-sm">{{ $comment->displayName() }}</span>
                <div class="mt-1.5 text-sm whitespace-pre-wrap">{{ $comment->body }}</div>

                <div x-data="{ open: false }">
                    <button @click="open = !open" class="text-xs text-ink2 mt-2">
                        <span x-text="open ? 'Cancel' : '↩ Reply'"></span>
                    </button>
                    <div x-show="open" x-cloak class="mt-3">
                        <x-comment-form :post="$post" :parent-id="$comment->id" compact />
                    </div>
                </div>
            </div>
        </div>

        @if ($comment->children->isNotEmpty())
        <div class="mt-3">
            <x-comment-thread :comments="$comment->children" :post="$post" :depth="min($depth + 1, 8)" />
        </div>
        @endif
    </div>
    @endforeach
</div>

The visual indentation caps at depth 8 so it doesn't collapse on mobile, but the actual data structure has no depth limit.

Optional GitHub OAuth

I didn't want to force anyone to create an account just to leave a comment. But I wanted to give the option for people who want a verified identity.

The flow is simple: click "Sign in with GitHub", get redirected, come back, and the form is pre-filled. The GitHub data is stored in the session. No user record is created in the database.

public function callback()
{
    $githubUser = Socialite::driver('github')->user();

    session([
        'github_user' => [
            'username' => $githubUser->getNickname(),
            'name'     => $githubUser->getName() ?? $githubUser->getNickname(),
            'email'    => $githubUser->getEmail(),
            'avatar'   => $githubUser->getAvatar(),
        ],
    ]);

    return redirect(session()->pull('github_return_url', '/'));
}

When a GitHub-authenticated guest comments, their github_avatar gets stored on the comment row directly, so there's no API call on render.

Filament moderation queue

I have a CommentResource in Filament that shows pending comments with a badge count in the nav. One click to approve, one click to reject.

The badge is a simple static method on the resource:

public static function getBadge(): ?string
{
    $count = Comment::where('is_approved', false)->count();
    return $count > 0 ? (string) $count : null;
}

Migrating from Disqus

Disqus lets you export everything from Settings → Export. You get an XML file with all threads (mapped to URLs) and posts (comments), including nesting via parent IDs.

I wrote an Artisan command to import it. The tricky part: you have to do it in two passes. First, create all comments without their parent_id. Second, go through and set the parent relationships using the Disqus IDs you mapped on the first pass.

One thing that tripped me up: the Disqus XML uses a default namespace (xmlns="http://disqus.com"), which means $xml->post in SimpleXML returns nothing. You have to use XPath with the namespace registered. And then, another gotcha, calling $node->xpath() on nodes obtained from an XPath result loop silently drops the namespace context. The fix is to use ->children('http://disqus.com') to access child elements instead of sub-XPath calls.

Here's the full command:

<?php

namespace App\Console\Commands;

use App\Models\Comment;
use App\Models\Post;
use Illuminate\Console\Command;

class ImportDisqusComments extends Command
{
    protected $signature = 'comments:import-disqus {file : Path to the Disqus XML export file}';

    protected $description = 'Import comments from a Disqus XML export file';

    private const NS = 'http://disqus.com';
    private const NS_DSQ = 'http://disqus.com/disqus-internals';

    public function handle(): int
    {
        $path = $this->argument('file');

        if (! file_exists($path)) {
            $this->error("File not found: {$path}");
            return self::FAILURE;
        }

        $this->info("Parsing {$path}…");

        libxml_use_internal_errors(true);
        $xml = simplexml_load_file($path);

        if (! $xml) {
            $this->error('Failed to parse XML.');
            return self::FAILURE;
        }

        $xml->registerXPathNamespace('ns', self::NS);
        $xml->registerXPathNamespace('dsq', self::NS_DSQ);

        // Build thread map: disqus thread id => local post id
        $this->info('Mapping threads to posts…');
        $threadMap = [];

        foreach ($xml->xpath('//ns:thread') as $thread) {
            $dsqId = (string) $thread->attributes(self::NS_DSQ)['id'];
            $link  = (string) $thread->children(self::NS)->link;
            $post  = Post::where('slug', $this->slugFromUrl($link))->first();
            if ($post) {
                $threadMap[$dsqId] = $post->id;
            }
        }

        $this->info('Found ' . count($threadMap) . ' matched threads.');

        // Pass 1: create all comments without parent_id yet
        // Important: use ->children() instead of ->xpath() on individual nodes.
        // Calling xpath() inside a foreach over xpath results silently loses
        // the registered namespace context. children() doesn't have this problem.
        $this->info('Importing comments (pass 1)…');
        $dsqToLocalId  = [];
        $pendingParents = [];

        $posts = $xml->xpath('//ns:post');
        $bar   = $this->output->createProgressBar(count($posts));

        foreach ($posts as $disqusPost) {
            $bar->advance();

            $c     = $disqusPost->children(self::NS);
            $dsqId = (string) $disqusPost->attributes(self::NS_DSQ)['id'];

            if ((string) $c->isDeleted === 'true' || (string) $c->isSpam === 'true') {
                continue;
            }

            $threadDsqId = (string) $c->thread->attributes(self::NS_DSQ)['id'];
            if (! isset($threadMap[$threadDsqId])) {
                continue;
            }

            if (isset($c->parent)) {
                $parentDsqId = (string) $c->parent->attributes(self::NS_DSQ)['id'];
                if ($parentDsqId) {
                    $pendingParents[$dsqId] = $parentDsqId;
                }
            }

            $authorName = (string) $c->author->children(self::NS)->name ?: 'Anonymous';

            $body = strip_tags((string) $c->message);
            $body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
            $body = trim($body);

            if ($body === '') {
                continue;
            }

            $comment = Comment::create([
                'post_id'        => $threadMap[$threadDsqId],
                'parent_id'      => null,
                'author_name'    => $authorName,
                'author_email'   => null,
                'github_username' => null,
                'github_avatar'  => null,
                'body'           => $body,
                'is_approved'    => true,
                'created_at'     => (string) $c->createdAt,
                'updated_at'     => (string) $c->createdAt,
            ]);

            $dsqToLocalId[$dsqId] = $comment->id;
        }

        $bar->finish();
        $this->newLine();

        // Pass 2: wire up parent_id now that all comments exist
        $this->info('Resolving parent relationships (pass 2)…');
        $resolved = 0;

        foreach ($pendingParents as $dsqId => $parentDsqId) {
            if (isset($dsqToLocalId[$dsqId], $dsqToLocalId[$parentDsqId])) {
                Comment::where('id', $dsqToLocalId[$dsqId])
                    ->update(['parent_id' => $dsqToLocalId[$parentDsqId]]);
                $resolved++;
            }
        }

        $imported = count($dsqToLocalId);
        $this->info("Done. Imported {$imported} comments, resolved {$resolved} nested replies.");

        return self::SUCCESS;
    }

    private function slugFromUrl(string $url): string
    {
        $path     = parse_url($url, PHP_URL_PATH);
        $path     = trim($path ?? '', '/');
        $segments = explode('/', $path);
        return end($segments) ?: $path;
    }
}

Once you have the file, running the import is just:

php artisan comments:import-disqus path/to/export.xml

In my case: 530 comments imported, 317 nested reply relationships resolved.

The result

No more Disqus. No more third-party tracking. No more ads I didn't ask for. Comments are stored in my own database, moderated from the same Filament admin I use for everything else, and I get an email when someone posts.

The whole thing (model, controller, Blade components, GitHub OAuth, Filament resource, Disqus importer) took one session to build. Honestly should have done this years ago.

If you have questions, leave a comment below. Obviously.