Build Reactive Signals for Bluesky's AT Protocol Firehose in Laravel

Quickstart Guide#

This guide will walk you through building your first Signal and consuming AT Protocol events in under 5 minutes.

Prerequisites#

Before starting, ensure you have:

  • Installed Signal in your Laravel application
  • Run php artisan signal:install successfully
  • Basic familiarity with Laravel

Your First Signal#

We'll build a Signal that logs every new post created on Bluesky.

Step 1: Generate a Signal#

Use the Artisan command to create a new Signal:

php artisan make:signal NewPostSignal

This creates app/Signals/NewPostSignal.php with a basic template.

Step 2: Define the Signal#

Open the generated file and update it:

<?php

namespace App\Signals;

use SocialDept\AtpSignals\Events\SignalEvent;
use SocialDept\AtpSignals\Signals\Signal;
use Illuminate\Support\Facades\Log;

class NewPostSignal extends Signal
{
    /**
     * Define which event types to listen for.
     */
    public function eventTypes(): array
    {
        return ['commit']; // Listen for repository commits
    }

    /**
     * Filter by specific collections.
     */
    public function collections(): ?array
    {
        return ['app.bsky.feed.post']; // Only handle posts
    }

    /**
     * Handle the event when it arrives.
     */
    public function handle(SignalEvent $event): void
    {
        $record = $event->getRecord();

        Log::info('New post created', [
            'author' => $event->did,
            'text' => $record->text ?? null,
            'created_at' => $record->createdAt ?? null,
        ]);
    }
}

Step 3: Start Consuming Events#

Run the consumer to start listening:

php artisan signal:consume

You should see output like:

Starting Signal consumer in jetstream mode...
Connecting to wss://jetstream2.us-east.bsky.network...
Connected! Listening for events...

Congratulations! Your Signal is now processing every new post on Bluesky in real-time. Check your Laravel logs to see the posts coming in.

Understanding What Just Happened#

Let's break down the Signal you created:

Event Types#

public function eventTypes(): array
{
    return ['commit'];
}

This tells Signal you want commit events, which represent changes to repositories (like creating posts, likes, follows, etc.).

Available event types:

  • commit - Repository commits (most common)
  • identity - Identity changes (handle updates)
  • account - Account status changes

Collections#

public function collections(): ?array
{
    return ['app.bsky.feed.post'];
}

This filters to only post collections. Without this filter, your Signal would receive all commit events for every collection type.

Common collections:

  • app.bsky.feed.post - Posts
  • app.bsky.feed.like - Likes
  • app.bsky.graph.follow - Follows
  • app.bsky.feed.repost - Reposts

Learn more about filtering →

Handler Method#

public function handle(SignalEvent $event): void
{
    $record = $event->getRecord();
    // Your logic here
}

This is where your code runs for each matching event. The $event object contains:

  • did - The user's DID (decentralized identifier)
  • timeUs - Timestamp in microseconds
  • commit - Commit details (collection, operation, record key)
  • getRecord() - The actual record data

Next Steps#

Now that you've built your first Signal, let's make it more useful.

Add More Filtering#

Track specific operations only:

use SocialDept\AtpSignals\Enums\SignalCommitOperation;

public function operations(): ?array
{
    return [SignalCommitOperation::Create]; // Only new posts, not edits
}

Learn more about filtering →

Process Events Asynchronously#

For expensive operations, use Laravel queues:

public function shouldQueue(): bool
{
    return true;
}

public function handle(SignalEvent $event): void
{
    // This now runs in a background job
    $this->performExpensiveAnalysis($event);
}

Learn more about queues →

Store Data#

Let's store posts in your database:

use App\Models\Post;

public function handle(SignalEvent $event): void
{
    $record = $event->getRecord();

    Post::updateOrCreate(
        [
            'did' => $event->did,
            'rkey' => $event->commit->rkey,
        ],
        [
            'text' => $record->text ?? null,
            'created_at' => $record->createdAt,
        ]
    );
}

Handle Multiple Collections#

Use wildcards to match multiple collections:

public function collections(): ?array
{
    return [
        'app.bsky.feed.*', // All feed events
    ];
}

public function handle(SignalEvent $event): void
{
    $collection = $event->getCollection();

    match ($collection) {
        'app.bsky.feed.post' => $this->handlePost($event),
        'app.bsky.feed.like' => $this->handleLike($event),
        'app.bsky.feed.repost' => $this->handleRepost($event),
        default => null,
    };
}

Building Something Real#

Let's build a simple engagement tracker:

<?php

namespace App\Signals;

use App\Models\EngagementMetric;
use SocialDept\AtpSignals\Enums\SignalCommitOperation;
use SocialDept\AtpSignals\Events\SignalEvent;
use SocialDept\AtpSignals\Signals\Signal;

class EngagementTrackerSignal extends Signal
{
    public function eventTypes(): array
    {
        return ['commit'];
    }

    public function collections(): ?array
    {
        return [
            'app.bsky.feed.post',
            'app.bsky.feed.like',
            'app.bsky.feed.repost',
        ];
    }

    public function operations(): ?array
    {
        return [SignalCommitOperation::Create];
    }

    public function shouldQueue(): bool
    {
        return true; // Process in background
    }

    public function handle(SignalEvent $event): void
    {
        EngagementMetric::create([
            'date' => now()->toDateString(),
            'collection' => $event->getCollection(),
            'event_type' => 'create',
            'count' => 1,
        ]);
    }
}

This Signal tracks all engagement activity (posts, likes, reposts) and stores metrics for analysis.

Testing Your Signal#

Before running in production, test your Signal with sample data:

php artisan signal:test NewPostSignal

This will run your Signal with a sample event and show you the output.

Learn more about testing →

Common Patterns#

Only Process Specific Users#

public function dids(): ?array
{
    return [
        'did:plc:z72i7hdynmk6r22z27h6tvur', // Specific user
    ];
}

Add Custom Filtering Logic#

public function shouldHandle(SignalEvent $event): bool
{
    $record = $event->getRecord();

    // Only handle posts with images
    return isset($record->embed);
}

Handle Failures Gracefully#

public function failed(SignalEvent $event, \Throwable $exception): void
{
    Log::error('Signal processing failed', [
        'event' => $event->toArray(),
        'error' => $exception->getMessage(),
    ]);

    // Optionally notify admins, store for retry, etc.
}

Running in Production#

Using Supervisor#

For production, run Signal under a process monitor like Supervisor:

[program:signal-consumer]
process_name=%(program_name)s
command=php /path/to/artisan signal:consume
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/path/to/logs/signal-consumer.log

Starting from Last Position#

Signal automatically saves cursor positions, so it resumes from where it left off:

php artisan signal:consume

To start fresh and ignore stored position:

php artisan signal:consume --fresh

To start from a specific cursor:

php artisan signal:consume --cursor=123456789

What's Next?#

You now know the basics of building Signals! Explore more advanced topics:

Getting Help#