···11+# Contributing
22+33+Contributions are **welcome** and will be fully **credited**.
44+55+## Etiquette
66+77+This project is open source, and as such, the maintainers give their free time to build and maintain the source code held within. They make the code freely available in the hope that it will be of use to other developers. It would be extremely unfair for them to suffer abuse or anger for their hard work.
88+99+Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the world that developers are civilized and selfless people.
1010+1111+It's the duty of the maintainer to ensure that all submissions to the project are of sufficient quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used.
1212+1313+## Viability
1414+1515+When requesting or submitting new features, first consider whether it might be useful to others. Open source projects are used by many developers, who may have entirely different needs to your own. Think about whether or not your feature is likely to be used by other users of the project.
1616+1717+## Procedure
1818+1919+### Before Filing an Issue
2020+2121+- Search existing issues to avoid duplicates
2222+- Check the [documentation](docs/) to ensure it's not a usage question
2323+- Provide a clear title and description
2424+- Include steps to reproduce the issue
2525+- Specify your environment (PHP version, Laravel version, Signal version, mode)
2626+- Include relevant code samples and full error messages
2727+2828+### Before Submitting a Pull Request
2929+3030+- **Discuss non-trivial changes first** by opening an issue
3131+- **Fork the repository** and create a feature branch from `main`
3232+- **Follow all requirements** listed below
3333+- **Write tests** for your changes
3434+- **Update documentation** if behavior changes
3535+- **Run code style checks** with `vendor/bin/php-cs-fixer fix`
3636+- **Ensure all tests pass** with `vendor/bin/phpunit`
3737+- **Write clear commit messages** that explain what and why
3838+3939+## Requirements
4040+4141+- **[PSR-12 Coding Standard](https://www.php-fig.org/psr/psr-12/)** - Run `vendor/bin/php-cs-fixer fix` to automatically fix code style issues.
4242+4343+- **Add tests** - Your patch won't be accepted if it doesn't have tests. All tests must use [PHPUnit](https://phpunit.de/).
4444+4545+- **Document any change in behaviour** - Make sure the `README.md`, `docs/`, and any other relevant documentation are kept up-to-date.
4646+4747+- **Consider our release cycle** - We follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option.
4848+4949+- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
5050+5151+- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting.
5252+5353+## Running Tests
5454+5555+```bash
5656+vendor/bin/phpunit
5757+```
5858+5959+## Code Style
6060+6161+Signal follows PSR-12 coding standard. Run PHP CS Fixer before submitting:
6262+6363+```bash
6464+vendor/bin/php-cs-fixer fix
6565+```
6666+6767+**Happy coding**!
+112-737
README.md
···11-# Signal
11+[](https://github.com/socialdept/signal)
2233-**Laravel package for building Signals that respond to AT Protocol events**
33+<h3 align="center">
44+ Consume real-time AT Protocol events in your Laravel application.
55+</h3>
4655-Signal provides a clean, Laravel-style interface for consuming real-time events from the AT Protocol. Supports both **Jetstream** (simplified JSON events) and **Firehose** (raw CBOR/CAR format) for maximum flexibility. Build reactive applications, AppViews, and custom indexers that respond to posts, likes, follows, and other social interactions on the AT Protocol network.
77+<p align="center">
88+ <br>
99+ <a href="https://packagist.org/packages/socialdept/signal" title="Latest Version on Packagist"><img src="https://img.shields.io/packagist/v/socialdept/signal.svg?style=flat-square"></a>
1010+ <a href="https://packagist.org/packages/socialdept/signal" title="Total Downloads"><img src="https://img.shields.io/packagist/dt/socialdept/signal.svg?style=flat-square"></a>
1111+ <a href="https://github.com/socialdept/signal/actions/workflows/tests.yml" title="GitHub Tests Action Status"><img src="https://img.shields.io/github/actions/workflow/status/socialdept/signal/tests.yml?branch=main&label=tests&style=flat-square"></a>
1212+ <a href="LICENSE" title="Software License"><img src="https://img.shields.io/github/license/socialdept/signal?style=flat-square"></a>
1313+</p>
614715---
81699-## Features
1717+## What is Signal?
10181111-- 🔄 **Dual-Mode Support** - Choose between Jetstream (JSON) or Firehose (CBOR/CAR) based on your needs
1212-- 🔌 **WebSocket Connection** - Connect to AT Protocol with automatic reconnection and exponential backoff
1313-- 🎯 **Signal-based Architecture** - Clean, testable event handlers (avoiding Laravel's "listener" naming collision)
1414-- ⭐ **Wildcard Collection Filtering** - Match multiple collections with patterns like `app.bsky.feed.*`
1515-- 🏗️ **AppView Ready** - Full support for custom collections and building AT Protocol AppViews
1616-- 💾 **Cursor Management** - Resume from last position after disconnections (Database, Redis, or File storage)
1717-- ⚡ **Queue Integration** - Process events asynchronously with Laravel queues
1818-- 🔍 **Auto-Discovery** - Automatically find and register Signals in `app/Signals`
1919-- 🧪 **Testing Tools** - Test your Signals with sample data
2020-- 🛠️ **Artisan Commands** - Full CLI support for managing and testing Signals
1919+**Signal** is a Laravel package that lets you respond to real-time events from the AT Protocol network. Build reactive applications, custom feeds, moderation tools, analytics systems, and AppViews by listening to posts, likes, follows, and other social interactions as they happen across Bluesky and the entire AT Protocol ecosystem.
21202222----
2121+Think of it as Laravel's event listeners, but for the decentralized social web.
23222424-## Table of Contents
2323+## Why use Signal?
25242626-<!-- TOC -->
2727-* [Installation](#installation)
2828-* [Quick Start](#quick-start)
2929-* [Jetstream vs Firehose](#jetstream-vs-firehose)
3030-* [Creating Signals](#creating-signals)
3131-* [Filtering Events](#filtering-events)
3232-* [Queue Integration](#queue-integration)
3333-* [Configuration](#configuration-1)
3434-* [Programmatic Usage](#programmatic-usage)
3535-* [Available Commands](#available-commands)
3636-* [Testing](#testing)
3737-* [External Resources](#external-resources)
3838-* [Examples](#examples)
3939-* [Requirements](#requirements)
4040-* [License](#license)
4141-* [Support](#support)
4242-<!-- TOC -->
4343-4444----
4545-4646-## Installation
4747-4848-Install the package via Composer:
2525+- **Laravel-style code** - Familiar patterns you already know
2626+- **Real-time processing** - React to events as they happen
2727+- **Dual-mode support** - Choose Jetstream (efficient JSON) or Firehose (comprehensive CBOR)
2828+- **AppView ready** - Full support for custom collections and protocols
2929+- **Production features** - Queue integration, cursor management, auto-reconnection
3030+- **Easy filtering** - Target specific collections, operations, and users with wildcards
3131+- **Built-in testing** - Test your signals with sample data
49325050-```bash
5151-composer require socialdept/signal
5252-```
5353-5454-Run the installation command:
5555-5656-```bash
5757-php artisan signal:install
5858-```
5959-6060-This will:
6161-- Publish the configuration file to `config/signal.php`
6262-- Publish the database migration
6363-- Run migrations (with confirmation)
6464-- Display next steps
6565-6666-### Manual Installation
6767-6868-If you prefer manual installation:
6969-7070-```bash
7171-php artisan vendor:publish --tag=signal-config
7272-php artisan vendor:publish --tag=signal-migrations
7373-php artisan migrate
7474-```
7575-7676----
7777-7878-## Quick Start
7979-8080-### 1. Create Your First Signal
8181-8282-```bash
8383-php artisan make:signal NewPostSignal
8484-```
8585-8686-This creates `app/Signals/NewPostSignal.php`:
3333+## Quick Example
87348835```php
8989-<?php
9090-9191-namespace App\Signals;
9292-9336use SocialDept\Signal\Events\SignalEvent;
9437use SocialDept\Signal\Signals\Signal;
9538···11760}
11861```
11962120120-### 2. Start Consuming Events
6363+Run `php artisan signal:consume` and start responding to every post on Bluesky in real-time.
12164122122-```bash
123123-php artisan signal:consume
124124-```
125125-126126-Your Signal will now respond to new posts on the AT Protocol network in real-time!
127127-128128----
129129-130130-## Jetstream vs Firehose
131131-132132-Signal supports two modes for consuming AT Protocol events. Choose based on your use case:
133133-134134-### Jetstream Mode (Default)
135135-136136-**Best for**: Standard Bluesky collections, production efficiency, lower bandwidth
6565+## Installation
1376613867```bash
139139-php artisan signal:consume --mode=jetstream
6868+composer require socialdept/signal
6969+php artisan signal:install
14070```
14171142142-**Characteristics:**
143143-- ✅ Simplified JSON events (easy to work with)
144144-- ✅ Server-side collection filtering (efficient)
145145-- ✅ Lower bandwidth and processing overhead
146146-- ⚠️ Only standard `app.bsky.*` collections get create/update operations
147147-- ⚠️ Custom collections only receive delete operations
7272+That's it. [Read the installation docs →](docs/installation.md)
14873149149-**Jetstream URL options:**
150150-- US East: `wss://jetstream2.us-east.bsky.network` (default)
151151-- US West: `wss://jetstream1.us-west.bsky.network`
7474+## Getting Started
15275153153-### Firehose Mode
7676+Once installed, you're three steps away from consuming AT Protocol events:
15477155155-**Best for**: Custom collections, AppViews, comprehensive indexing
7878+### 1. Create a Signal
1567915780```bash
158158-php artisan signal:consume --mode=firehose
159159-```
160160-161161-**Characteristics:**
162162-- ✅ **All operations** (create, update, delete) for **all collections**
163163-- ✅ Perfect for custom collections (e.g., `app.yourapp.*.collection`)
164164-- ✅ Full CBOR/CAR decoding with package `revolution/laravel-bluesky`
165165-- ⚠️ Client-side filtering only (higher bandwidth)
166166-- ⚠️ More processing overhead
167167-168168-**When to use Firehose:**
169169-- Building an AT Protocol AppView
170170-- Working with custom collections
171171-- Need create/update events for non-standard collections
172172-- Building comprehensive indexes
173173-174174-### Configuration
175175-176176-Set your preferred mode in `.env`:
177177-178178-```env
179179-# Use Jetstream (default)
180180-SIGNAL_MODE=jetstream
181181-182182-# Or use Firehose for custom collections
183183-SIGNAL_MODE=firehose
8181+php artisan make:signal NewPostSignal
18482```
18583186186-### Example: Custom Collections
187187-188188-If you're tracking custom collections like `app.offprint.beta.publication`, you **must** use Firehose mode:
8484+### 2. Define What to Listen For
1898519086```php
191191-class PublicationSignal extends Signal
8787+public function collections(): ?array
19288{
193193- public function collections(): ?array
194194- {
195195- return ['app.offprint.beta.publication'];
196196- }
197197-198198- public function handle(SignalEvent $event): void
199199- {
200200- // With Jetstream: Only sees deletes ❌
201201- // With Firehose: Sees creates, updates, deletes ✅
202202- }
8989+ return ['app.bsky.feed.post'];
20390}
20491```
20592206206----
9393+### 3. Start Consuming
20794208208-## Creating Signals
9595+```bash
9696+php artisan signal:consume
9797+```
20998210210-### Basic Signal Structure
9999+Your Signal will now handle every matching event from the network. [Read the quickstart guide →](docs/quickstart.md)
211100212212-Every Signal extends the base `Signal` class and must implement:
101101+## What can you build?
213102214214-```php
215215-use SocialDept\Signal\Enums\SignalEventType;
216216-use SocialDept\Signal\Events\SignalEvent;
217217-use SocialDept\Signal\Signals\Signal;
103103+- **Custom feeds** - Curate content based on your own algorithms
104104+- **Moderation tools** - Detect and flag problematic content automatically
105105+- **Analytics platforms** - Track engagement, trends, and network growth
106106+- **Social integrations** - Mirror content to other platforms in real-time
107107+- **Notification systems** - Alert users about relevant activity
108108+- **AppViews** - Build custom AT Protocol applications with your own collections
218109219219-class MySignal extends Signal
220220-{
221221- // Required: Define which event types to listen for
222222- public function eventTypes(): array
223223- {
224224- return [SignalEventType::Commit];
110110+## Documentation
225111226226- // Or use strings:
227227- // return ['commit'];
228228- }
112112+**Getting Started**
113113+- [Installation](docs/installation.md) - Detailed setup instructions
114114+- [Quickstart Guide](docs/quickstart.md) - Build your first Signal
115115+- [Jetstream vs Firehose](docs/modes.md) - Choose the right mode
229116230230- // Required: Handle the event
231231- public function handle(SignalEvent $event): void
232232- {
233233- // Your logic here
234234- }
235235-}
236236-```
117117+**Building Signals**
118118+- [Creating Signals](docs/signals.md) - Complete Signal reference
119119+- [Filtering Events](docs/filtering.md) - Target specific collections and operations
120120+- [Queue Integration](docs/queues.md) - Process events asynchronously
237121238238-**Enums vs Strings**: Signal supports both typed enums and strings for better IDE support and type safety. Use whichever you prefer!
122122+**Advanced**
123123+- [Configuration](docs/configuration.md) - All config options explained
124124+- [Testing](docs/testing.md) - Test your Signals
125125+- [Examples](docs/examples.md) - Real-world use cases
239126240240-### Event Types
241241-242242-Three event types are available:
127127+## Example Use Cases
243128244244-| Enum | String | Description | Use Cases |
245245-|-----------------------------|--------------|--------------------------------------------------|---------------------------------------|
246246-| `SignalEventType::Commit` | `'commit'` | Repository commits (posts, likes, follows, etc.) | Content creation, social interactions |
247247-| `SignalEventType::Identity` | `'identity'` | Identity changes (handle updates) | User profile tracking |
248248-| `SignalEventType::Account` | `'account'` | Account status changes | Account monitoring |
249249-250250-### Accessing Event Data
251251-129129+### Track User Growth
252130```php
253253-use SocialDept\Signal\Enums\SignalCommitOperation;
254254-255255-public function handle(SignalEvent $event): void
131131+public function collections(): ?array
256132{
257257- // Common properties
258258- $did = $event->did; // User's DID
259259- $kind = $event->kind; // Event type
260260- $timestamp = $event->timeUs; // Microsecond timestamp
261261-262262- // Commit events
263263- if ($event->isCommit()) {
264264- $collection = $event->getCollection(); // e.g., 'app.bsky.feed.post'
265265- $operation = $event->getOperation(); // SignalCommitOperation enum
266266- $record = $event->getRecord(); // The actual record data
267267- $rkey = $event->commit->rkey; // Record key
268268-269269- // Use enum for type-safe comparisons
270270- if ($operation === SignalCommitOperation::Create) {
271271- // Handle new records
272272- }
273273-274274- // Or get string value
275275- $operationString = $operation->value; // 'create', 'update', or 'delete'
276276- }
277277-278278- // Identity events
279279- if ($event->isIdentity()) {
280280- $handle = $event->identity->handle;
281281- }
282282-283283- // Account events
284284- if ($event->isAccount()) {
285285- $active = $event->account->active;
286286- $status = $event->account->status;
287287- }
133133+ return ['app.bsky.graph.follow'];
288134}
289135```
290136291291----
292292-293293-## Filtering Events
294294-295295-### Collection Filtering (with Wildcards!)
296296-297297-Filter events by AT Protocol collection.
298298-299299-**Important**:
300300-- **Jetstream mode**: Exact collection names are sent as URL parameters for server-side filtering. Wildcards work for client-side filtering only.
301301-- **Firehose mode**: All filtering is client-side. Wildcards work normally.
302302-137137+### Monitor Content Moderation
303138```php
304304-// Exact match - only posts
305305-public function collections(): ?array
306306-{
307307- return ['app.bsky.feed.post'];
308308-}
309309-310310-// Wildcard - all feed events
311139public function collections(): ?array
312140{
313141 return ['app.bsky.feed.*'];
314142}
315143316316-// Multiple patterns
317317-public function collections(): ?array
318318-{
319319- return [
320320- 'app.bsky.feed.post',
321321- 'app.bsky.feed.repost',
322322- 'app.bsky.graph.*', // All graph collections
323323- ];
324324-}
325325-326326-// No filter - all collections
327327-public function collections(): ?array
144144+public function shouldQueue(): bool
328145{
329329- return null;
146146+ return true; // Process in background
330147}
331148```
332149333333-### Common Collection Patterns
334334-335335-| Pattern | Matches |
336336-|--------------------|-----------------------------|
337337-| `app.bsky.feed.*` | Posts, likes, reposts, etc. |
338338-| `app.bsky.graph.*` | Follows, blocks, mutes |
339339-| `app.bsky.actor.*` | Profile updates |
340340-| `app.bsky.*` | All Bluesky collections |
341341-342342-### Operation Filtering
343343-344344-Filter events by operation type (only applies to `commit` events):
345345-150150+### Build Custom Collections (AppView)
346151```php
347347-use SocialDept\Signal\Enums\SignalCommitOperation;
348348-349349-// Only handle creates (using enum)
350350-public function operations(): ?array
152152+public function collections(): ?array
351153{
352352- return [SignalCommitOperation::Create];
353353-}
354354-355355-// Only handle creates and updates (using enums)
356356-public function operations(): ?array
357357-{
358358- return [
359359- SignalCommitOperation::Create,
360360- SignalCommitOperation::Update,
361361- ];
362362-}
363363-364364-// Only handle deletes (using string)
365365-public function operations(): ?array
366366-{
367367- return ['delete'];
368368-}
369369-370370-// No filter - all operations (default)
371371-public function operations(): ?array
372372-{
373373- return null;
154154+ return ['app.yourapp.custom.collection'];
374155}
375156```
376157377377-**Available operations:**
378378-379379-| Enum | String | Description |
380380-|---------------------------------|------------|---------------------------|
381381-| `SignalCommitOperation::Create` | `'create'` | New records created |
382382-| `SignalCommitOperation::Update` | `'update'` | Existing records modified |
383383-| `SignalCommitOperation::Delete` | `'delete'` | Records removed |
384384-385385-**Example use cases:**
386386-```php
387387-use SocialDept\Signal\Enums\SignalCommitOperation;
388388-389389-// Signal that only handles new posts (not edits)
390390-class NewPostSignal extends Signal
391391-{
392392- public function collections(): ?array
393393- {
394394- return ['app.bsky.feed.post'];
395395- }
158158+[See more examples →](docs/examples.md)
396159397397- public function operations(): ?array
398398- {
399399- return [SignalCommitOperation::Create];
400400- }
401401-}
160160+## Key Features Explained
402161403403-// Signal that only handles content updates
404404-class ContentUpdateSignal extends Signal
405405-{
406406- public function collections(): ?array
407407- {
408408- return ['app.bsky.feed.post'];
409409- }
162162+### Jetstream vs Firehose
410163411411- public function operations(): ?array
412412- {
413413- return [SignalCommitOperation::Update];
414414- }
415415-}
164164+Signal supports two modes for consuming AT Protocol events:
416165417417-// Signal that handles deletions for cleanup
418418-class CleanupSignal extends Signal
419419-{
420420- public function collections(): ?array
421421- {
422422- return ['app.bsky.feed.*'];
423423- }
166166+- **Jetstream** (default) - Simplified JSON events with server-side filtering
167167+- **Firehose** - Raw CBOR/CAR format with client-side filtering
424168425425- public function operations(): ?array
426426- {
427427- return [SignalCommitOperation::Delete];
428428- }
429429-}
430430-```
169169+[Learn more about modes →](docs/modes.md)
431170432432-### DID Filtering
171171+### Wildcard Filtering
433172434434-Filter events by specific users:
173173+Match multiple collections with patterns:
435174436175```php
437437-public function dids(): ?array
176176+public function collections(): ?array
438177{
439178 return [
440440- 'did:plc:z72i7hdynmk6r22z27h6tvur', // Specific user
441441- 'did:plc:ragtjsm2j2vknwkz3zp4oxrd', // Another user
179179+ 'app.bsky.feed.*', // All feed events
180180+ 'app.bsky.graph.*', // All graph events
181181+ 'app.yourapp.*', // All your custom collections
442182 ];
443183}
444184```
445185446446-### Custom Filtering
186186+[Learn more about filtering →](docs/filtering.md)
447187448448-Add complex filtering logic:
188188+### Queue Integration
449189450450-```php
451451-public function shouldHandle(SignalEvent $event): bool
452452-{
453453- // Only handle posts with images
454454- if ($event->isCommit() && $event->commit->collection === 'app.bsky.feed.post') {
455455- $record = $event->getRecord();
456456- return isset($record->embed);
457457- }
458458-459459- return true;
460460-}
461461-```
462462-463463----
464464-465465-## Queue Integration
466466-467467-Process events asynchronously using Laravel queues:
190190+Process events asynchronously for better performance:
468191469192```php
470470-class HeavyProcessingSignal extends Signal
193193+public function shouldQueue(): bool
471194{
472472- public function eventTypes(): array
473473- {
474474- return ['commit'];
475475- }
476476-477477- // Enable queueing
478478- public function shouldQueue(): bool
479479- {
480480- return true;
481481- }
482482-483483- // Optional: Customize queue
484484- public function queue(): string
485485- {
486486- return 'high-priority';
487487- }
488488-489489- // Optional: Customize connection
490490- public function queueConnection(): string
491491- {
492492- return 'redis';
493493- }
494494-495495- public function handle(SignalEvent $event): void
496496- {
497497- // This runs in a queue job
498498- $this->performExpensiveOperation($event);
499499- }
500500-501501- // Handle failures
502502- public function failed(SignalEvent $event, \Throwable $exception): void
503503- {
504504- Log::error('Signal failed', [
505505- 'event' => $event->toArray(),
506506- 'error' => $exception->getMessage(),
507507- ]);
508508- }
509509-}
510510-```
511511-512512----
513513-514514-## Configuration
515515-516516-Configuration is stored in `config/signal.php`:
517517-518518-### Consumer Mode
519519-520520-Choose between Jetstream (JSON) or Firehose (CBOR) mode:
521521-522522-```php
523523-'mode' => env('SIGNAL_MODE', 'jetstream'),
524524-```
525525-526526-Options:
527527-- `jetstream` - JSON events, server-side filtering (default)
528528-- `firehose` - CBOR events, client-side filtering (required for custom collections)
529529-530530-### Jetstream Configuration
531531-532532-```php
533533-'websocket_url' => env('SIGNAL_JETSTREAM_URL', 'wss://jetstream2.us-east.bsky.network'),
534534-```
535535-536536-Available endpoints:
537537-- **US East**: `wss://jetstream2.us-east.bsky.network` (default)
538538-- **US West**: `wss://jetstream1.us-west.bsky.network`
539539-540540-### Firehose Configuration
541541-542542-```php
543543-'firehose' => [
544544- 'host' => env('SIGNAL_FIREHOSE_HOST', 'bsky.network'),
545545-],
546546-```
547547-548548-The raw firehose endpoint is: `wss://{host}/xrpc/com.atproto.sync.subscribeRepos`
549549-550550-### Cursor Storage
551551-552552-Choose how to store cursor positions:
553553-554554-```php
555555-'cursor_storage' => env('SIGNAL_CURSOR_STORAGE', 'database'),
556556-```
557557-558558-| Driver | Best For | Configuration |
559559-|------------|-------------------------------|--------------------|
560560-| `database` | Production, multi-server | Default connection |
561561-| `redis` | High performance, distributed | Redis connection |
562562-| `file` | Development, single server | Storage path |
563563-564564-### Environment Variables
565565-566566-Add to your `.env`:
567567-568568-```env
569569-# Consumer Mode
570570-SIGNAL_MODE=jetstream # or 'firehose' for custom collections
571571-572572-# Jetstream Configuration
573573-SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network
574574-575575-# Firehose Configuration (only needed if using firehose mode)
576576-SIGNAL_FIREHOSE_HOST=bsky.network
577577-578578-# Optional Configuration
579579-SIGNAL_CURSOR_STORAGE=database
580580-SIGNAL_QUEUE_CONNECTION=redis
581581-SIGNAL_QUEUE=signal
582582-SIGNAL_BATCH_SIZE=100
583583-SIGNAL_RATE_LIMIT=1000
584584-```
585585-586586-### Auto-Discovery
587587-588588-Signals are automatically discovered from `app/Signals`. Disable if needed:
589589-590590-```php
591591-'auto_discovery' => [
592592- 'enabled' => true,
593593- 'path' => app_path('Signals'),
594594- 'namespace' => 'App\\Signals',
595595-],
596596-```
597597-598598-Or manually register Signals:
599599-600600-```php
601601-'signals' => [
602602- \App\Signals\NewPostSignal::class,
603603- \App\Signals\NewFollowSignal::class,
604604-],
605605-```
606606-607607----
608608-609609-## Programmatic Usage
610610-611611-You can start and stop the consumer programmatically using the `Signal` facade:
612612-613613-```php
614614-use SocialDept\Signal\Facades\Signal;
615615-616616-// Start consuming events (uses mode from config)
617617-Signal::start();
618618-619619-// Start from a specific cursor
620620-Signal::start(cursor: 123456789);
621621-622622-// Check which mode is active
623623-$mode = Signal::getMode(); // Returns 'jetstream' or 'firehose'
624624-625625-// Stop consuming events
626626-Signal::stop();
627627-```
628628-629629-The facade automatically resolves the correct consumer (Jetstream or Firehose) based on your `config('signal.mode')` setting. This allows you to:
630630-631631-- Switch between modes by changing configuration
632632-- Start consumers from application code (e.g., in a custom command)
633633-- Integrate Signal into existing application workflows
634634-635635-```php
636636-// Example: Start consumer based on environment
637637-if (app()->environment('production')) {
638638- config(['signal.mode' => 'jetstream']); // Use efficient Jetstream
639639-} else {
640640- config(['signal.mode' => 'firehose']); // Use comprehensive Firehose for testing
195195+ return true;
641196}
642642-643643-Signal::start();
644197```
645198646646----
199199+[Learn more about queues →](docs/queues.md)
647200648201## Available Commands
649649-650650-### `signal:install`
651651-Install the package (publish config, migrations, run migrations)
652202653203```bash
204204+# Install Signal
654205php artisan signal:install
655655-```
656656-657657-### `signal:consume`
658658-Start consuming events from AT Protocol
659659-660660-```bash
661661-# Use default mode from config
662662-php artisan signal:consume
663206664664-# Override mode
665665-php artisan signal:consume --mode=jetstream
666666-php artisan signal:consume --mode=firehose
667667-668668-# Start from specific cursor
669669-php artisan signal:consume --cursor=123456789
670670-671671-# Start fresh (ignore stored cursor)
672672-php artisan signal:consume --fresh
207207+# Create a new Signal
208208+php artisan make:signal YourSignal
673209674674-# Combine options
675675-php artisan signal:consume --mode=firehose --fresh
676676-```
677677-678678-### `signal:list`
679679-List all registered Signals
680680-681681-```bash
210210+# List all registered Signals
682211php artisan signal:list
683683-```
684684-685685-### `signal:make`
686686-Create a new Signal class
687687-688688-```bash
689689-php artisan make:signal NewPostSignal
690212691691-# With options
692692-php artisan make:signal FollowSignal --type=commit --collection=app.bsky.graph.follow
693693-```
694694-695695-### `signal:test`
696696-Test a Signal with sample data
697697-698698-```bash
699699-php artisan signal:test NewPostSignal
700700-```
701701-702702----
703703-704704-## Testing
705705-706706-Signal includes a comprehensive test suite. Test your Signals:
707707-708708-### Unit Testing
709709-710710-```php
711711-use SocialDept\Signal\Events\CommitEvent;
712712-use SocialDept\Signal\Events\SignalEvent;
713713-714714-class NewPostSignalTest extends TestCase
715715-{
716716- /** @test */
717717- public function it_handles_new_posts()
718718- {
719719- $signal = new NewPostSignal();
213213+# Start consuming events
214214+php artisan signal:consume
720215721721- $event = new SignalEvent(
722722- did: 'did:plc:test',
723723- timeUs: time() * 1000000,
724724- kind: 'commit',
725725- commit: new CommitEvent(
726726- rev: 'test',
727727- operation: 'create',
728728- collection: 'app.bsky.feed.post',
729729- rkey: 'test',
730730- record: (object) [
731731- 'text' => 'Hello World!',
732732- 'createdAt' => now()->toIso8601String(),
733733- ],
734734- ),
735735- );
736736-737737- $signal->handle($event);
738738-739739- // Assert your expected behavior
740740- }
741741-}
216216+# Test a Signal with sample data
217217+php artisan signal:test YourSignal
742218```
743219744744-### Testing with Artisan
220220+## Requirements
745221746746-```bash
747747-php artisan signal:test NewPostSignal
748748-```
222222+- PHP 8.2+
223223+- Laravel 11+
224224+- WebSocket support (enabled by default)
749225750750----
751751-752752-## External Resources
226226+## Resources
753227754228- [AT Protocol Documentation](https://atproto.com/)
229229+- [Bluesky API Docs](https://docs.bsky.app/)
755230- [Firehose Documentation](https://docs.bsky.app/docs/advanced-guides/firehose)
756756-- [Bluesky Lexicon](https://atproto.com/lexicons)
757757-758758----
759759-760760-## Examples
761761-762762-### Monitor All Feed Activity
763763-764764-```php
765765-class FeedMonitorSignal extends Signal
766766-{
767767- public function eventTypes(): array
768768- {
769769- return ['commit'];
770770- }
771771-772772- public function collections(): ?array
773773- {
774774- return ['app.bsky.feed.*'];
775775- }
776776-777777- public function handle(SignalEvent $event): void
778778- {
779779- // Handles posts, likes, reposts, etc.
780780- Log::info('Feed activity', [
781781- 'collection' => $event->getCollection(),
782782- 'operation' => $event->getOperation(),
783783- 'did' => $event->did,
784784- ]);
785785- }
786786-}
787787-```
788788-789789-### Track New Follows
790790-791791-```php
792792-class NewFollowSignal extends Signal
793793-{
794794- public function eventTypes(): array
795795- {
796796- return ['commit'];
797797- }
798798-799799- public function collections(): ?array
800800- {
801801- return ['app.bsky.graph.follow'];
802802- }
803803-804804- public function handle(SignalEvent $event): void
805805- {
806806- if ($event->commit->isCreate()) {
807807- $record = $event->getRecord();
808808-809809- // Store follow relationship
810810- Follow::create([
811811- 'follower_did' => $event->did,
812812- 'following_did' => $record->subject,
813813- ]);
814814- }
815815- }
816816-}
817817-```
818818-819819-### Content Moderation
820820-821821-```php
822822-class ModerationSignal extends Signal
823823-{
824824- public function eventTypes(): array
825825- {
826826- return ['commit'];
827827- }
231231+- [Jetstream Documentation](https://github.com/bluesky-social/jetstream)
828232829829- public function collections(): ?array
830830- {
831831- return ['app.bsky.feed.post'];
832832- }
233233+## Support & Contributing
833234834834- public function shouldQueue(): bool
835835- {
836836- return true;
837837- }
235235+Found a bug or have a feature request? [Open an issue](https://github.com/socialdept/signal/issues).
838236839839- public function handle(SignalEvent $event): void
840840- {
841841- $record = $event->getRecord();
237237+Want to contribute? We'd love your help! Check out the [contribution guidelines](CONTRIBUTING.md).
842238843843- if ($this->containsProhibitedContent($record->text)) {
844844- $this->flagForModeration($event->did, $record);
845845- }
846846- }
847847-}
848848-```
239239+## Credits
849240850850----
851851-852852-## Requirements
853853-854854-- PHP 8.2 or higher
855855-- Laravel 11.0 or higher
856856-- WebSocket support (enabled by default in most environments)
857857-858858----
241241+- [Miguel Batres](https://batres.co) - founder & lead maintainer
242242+- [All contributors](https://github.com/socialdept/signal/graphs/contributors)
859243860244## License
861245862862-The MIT License (MIT). Please see [LICENSE](LICENSE) for more information.
246246+Signal is open-source software licensed under the [MIT license](LICENSE).
863247864248---
865249866866-## Support
867867-868868-For issues, questions, or feature requests:
869869-- Read the [README.md](./README.md) before opening issues
870870-- Search through existing issues
871871-- Open new issue
872872-873873----
874874-875875-**Built for the AT Protocol ecosystem** • Made with ❤️ by Social Dept
250250+**Built for the Federation** • By Social Dept.
+687
docs/configuration.md
···11+# Configuration
22+33+Signal's configuration file provides complete control over how your application consumes AT Protocol events.
44+55+## Configuration File
66+77+After installation, configuration lives in `config/signal.php`.
88+99+### Publishing Configuration
1010+1111+Publish the config file manually if needed:
1212+1313+```bash
1414+php artisan vendor:publish --tag=signal-config
1515+```
1616+1717+This creates `config/signal.php` with all available options.
1818+1919+## Environment Variables
2020+2121+Most configuration can be set via `.env` for environment-specific values.
2222+2323+### Basic Configuration
2424+2525+```env
2626+# Consumer Mode (jetstream or firehose)
2727+SIGNAL_MODE=jetstream
2828+2929+# Jetstream Configuration
3030+SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network
3131+3232+# Firehose Configuration
3333+SIGNAL_FIREHOSE_HOST=bsky.network
3434+3535+# Cursor Storage (database, redis, or file)
3636+SIGNAL_CURSOR_STORAGE=database
3737+3838+# Queue Configuration
3939+SIGNAL_QUEUE_CONNECTION=redis
4040+SIGNAL_QUEUE=signal
4141+```
4242+4343+## Consumer Mode
4444+4545+Choose between Jetstream and Firehose mode.
4646+4747+### Configuration
4848+4949+```php
5050+'mode' => env('SIGNAL_MODE', 'jetstream'),
5151+```
5252+5353+**Options:**
5454+- `jetstream` - JSON events, server-side filtering (default)
5555+- `firehose` - CBOR/CAR events, client-side filtering
5656+5757+**Environment Variable:**
5858+```env
5959+SIGNAL_MODE=jetstream
6060+```
6161+6262+**When to use each:**
6363+- **Jetstream**: Standard Bluesky collections, production efficiency
6464+- **Firehose**: Custom collections, AppViews, comprehensive indexing
6565+6666+[Learn more about modes →](modes.md)
6767+6868+## Jetstream Configuration
6969+7070+Configuration specific to Jetstream mode.
7171+7272+### WebSocket URL
7373+7474+```php
7575+'jetstream' => [
7676+ 'websocket_url' => env(
7777+ 'SIGNAL_JETSTREAM_URL',
7878+ 'wss://jetstream2.us-east.bsky.network'
7979+ ),
8080+],
8181+```
8282+8383+**Available Endpoints:**
8484+8585+**US East (Default):**
8686+```env
8787+SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network
8888+```
8989+9090+**US West:**
9191+```env
9292+SIGNAL_JETSTREAM_URL=wss://jetstream1.us-west.bsky.network
9393+```
9494+9595+Choose the endpoint closest to your server for best latency.
9696+9797+## Firehose Configuration
9898+9999+Configuration specific to Firehose mode.
100100+101101+### Host
102102+103103+```php
104104+'firehose' => [
105105+ 'host' => env('SIGNAL_FIREHOSE_HOST', 'bsky.network'),
106106+],
107107+```
108108+109109+The WebSocket URL is constructed as:
110110+```
111111+wss://{host}/xrpc/com.atproto.sync.subscribeRepos
112112+```
113113+114114+**Environment Variable:**
115115+```env
116116+SIGNAL_FIREHOSE_HOST=bsky.network
117117+```
118118+119119+**Default Host:** `bsky.network`
120120+121121+**Custom Hosts:** If you're running your own AT Protocol PDS, specify it here:
122122+```env
123123+SIGNAL_FIREHOSE_HOST=my-pds.example.com
124124+```
125125+126126+## Cursor Storage
127127+128128+Configure how Signal stores cursor positions for resuming after disconnections.
129129+130130+### Storage Driver
131131+132132+```php
133133+'cursor_storage' => env('SIGNAL_CURSOR_STORAGE', 'database'),
134134+```
135135+136136+**Available Drivers:**
137137+- `database` - Store in database (recommended for production)
138138+- `redis` - Store in Redis (high performance)
139139+- `file` - Store in filesystem (development only)
140140+141141+**Environment Variable:**
142142+```env
143143+SIGNAL_CURSOR_STORAGE=database
144144+```
145145+146146+### Database Driver
147147+148148+Uses Laravel's default database connection.
149149+150150+**Configuration:**
151151+```php
152152+'cursor_storage' => 'database',
153153+```
154154+155155+**Requires:**
156156+- Migration published and run
157157+- Database connection configured
158158+159159+**Table:** `signal_cursors`
160160+161161+### Redis Driver
162162+163163+Stores cursors in Redis for high performance.
164164+165165+**Configuration:**
166166+```php
167167+'cursor_storage' => 'redis',
168168+169169+'redis' => [
170170+ 'connection' => env('SIGNAL_REDIS_CONNECTION', 'default'),
171171+ 'key_prefix' => env('SIGNAL_REDIS_PREFIX', 'signal:cursor:'),
172172+],
173173+```
174174+175175+**Environment Variables:**
176176+```env
177177+SIGNAL_CURSOR_STORAGE=redis
178178+SIGNAL_REDIS_CONNECTION=default
179179+SIGNAL_REDIS_PREFIX=signal:cursor:
180180+```
181181+182182+**Requires:**
183183+- Redis connection configured in `config/database.php`
184184+- Redis server running
185185+186186+### File Driver
187187+188188+Stores cursors in the filesystem (development only).
189189+190190+**Configuration:**
191191+```php
192192+'cursor_storage' => 'file',
193193+194194+'file' => [
195195+ 'path' => env('SIGNAL_FILE_PATH', storage_path('app/signal')),
196196+],
197197+```
198198+199199+**Environment Variables:**
200200+```env
201201+SIGNAL_CURSOR_STORAGE=file
202202+SIGNAL_FILE_PATH=/path/to/storage/signal
203203+```
204204+205205+**Not recommended for production:**
206206+- Single server only
207207+- No clustering support
208208+- Filesystem I/O overhead
209209+210210+## Queue Configuration
211211+212212+Configure how Signal dispatches queued jobs.
213213+214214+### Queue Connection
215215+216216+```php
217217+'queue' => [
218218+ 'connection' => env('SIGNAL_QUEUE_CONNECTION', null),
219219+ 'queue' => env('SIGNAL_QUEUE', 'default'),
220220+],
221221+```
222222+223223+**Environment Variables:**
224224+```env
225225+# Queue connection (redis, database, sqs, etc.)
226226+SIGNAL_QUEUE_CONNECTION=redis
227227+228228+# Queue name
229229+SIGNAL_QUEUE=signal
230230+```
231231+232232+**Defaults:**
233233+- `connection`: Uses Laravel's default queue connection
234234+- `queue`: Uses Laravel's default queue name
235235+236236+### Per-Signal Configuration
237237+238238+Signals can override queue configuration:
239239+240240+```php
241241+public function shouldQueue(): bool
242242+{
243243+ return true;
244244+}
245245+246246+public function queueConnection(): string
247247+{
248248+ return 'redis'; // Override connection
249249+}
250250+251251+public function queue(): string
252252+{
253253+ return 'high-priority'; // Override queue name
254254+}
255255+```
256256+257257+[Learn more about queue integration →](queues.md)
258258+259259+## Auto-Discovery
260260+261261+Configure automatic Signal discovery.
262262+263263+### Enable/Disable
264264+265265+```php
266266+'auto_discovery' => [
267267+ 'enabled' => true,
268268+ 'path' => app_path('Signals'),
269269+ 'namespace' => 'App\\Signals',
270270+],
271271+```
272272+273273+**Options:**
274274+- `enabled`: Enable/disable auto-discovery (default: `true`)
275275+- `path`: Directory to scan for Signals (default: `app/Signals`)
276276+- `namespace`: Namespace for discovered Signals (default: `App\Signals`)
277277+278278+### Disable Auto-Discovery
279279+280280+Manually register Signals instead:
281281+282282+```php
283283+'auto_discovery' => [
284284+ 'enabled' => false,
285285+],
286286+287287+'signals' => [
288288+ \App\Signals\NewPostSignal::class,
289289+ \App\Signals\NewFollowSignal::class,
290290+],
291291+```
292292+293293+### Custom Discovery Path
294294+295295+Organize Signals in a custom directory:
296296+297297+```php
298298+'auto_discovery' => [
299299+ 'enabled' => true,
300300+ 'path' => app_path('Domain/Signals'),
301301+ 'namespace' => 'App\\Domain\\Signals',
302302+],
303303+```
304304+305305+## Manual Signal Registration
306306+307307+Register Signals explicitly.
308308+309309+### Configuration
310310+311311+```php
312312+'signals' => [
313313+ \App\Signals\NewPostSignal::class,
314314+ \App\Signals\NewFollowSignal::class,
315315+ \App\Signals\ProfileUpdateSignal::class,
316316+],
317317+```
318318+319319+**When to use:**
320320+- Auto-discovery disabled
321321+- Signals outside standard directory
322322+- Fine-grained control over which Signals run
323323+324324+## Logging
325325+326326+Signal uses Laravel's logging system.
327327+328328+### Configure Logging
329329+330330+Standard Laravel log configuration applies:
331331+332332+```php
333333+// config/logging.php
334334+'channels' => [
335335+ 'signal' => [
336336+ 'driver' => 'daily',
337337+ 'path' => storage_path('logs/signal.log'),
338338+ 'level' => env('SIGNAL_LOG_LEVEL', 'debug'),
339339+ 'days' => 14,
340340+ ],
341341+],
342342+```
343343+344344+Use in Signals:
345345+346346+```php
347347+use Illuminate\Support\Facades\Log;
348348+349349+public function handle(SignalEvent $event): void
350350+{
351351+ Log::channel('signal')->info('Processing event', [
352352+ 'did' => $event->did,
353353+ ]);
354354+}
355355+```
356356+357357+## Complete Configuration Reference
358358+359359+Here's the full `config/signal.php` with all options:
360360+361361+```php
362362+<?php
363363+364364+return [
365365+366366+ /*
367367+ |--------------------------------------------------------------------------
368368+ | Consumer Mode
369369+ |--------------------------------------------------------------------------
370370+ |
371371+ | Choose between 'jetstream' (JSON events) or 'firehose' (CBOR/CAR events).
372372+ | Jetstream is more efficient for standard Bluesky collections.
373373+ | Firehose is required for custom collections.
374374+ |
375375+ | Options: 'jetstream', 'firehose'
376376+ |
377377+ */
378378+379379+ 'mode' => env('SIGNAL_MODE', 'jetstream'),
380380+381381+ /*
382382+ |--------------------------------------------------------------------------
383383+ | Jetstream Configuration
384384+ |--------------------------------------------------------------------------
385385+ |
386386+ | Configuration for Jetstream mode (JSON events).
387387+ |
388388+ */
389389+390390+ 'jetstream' => [
391391+ 'websocket_url' => env(
392392+ 'SIGNAL_JETSTREAM_URL',
393393+ 'wss://jetstream2.us-east.bsky.network'
394394+ ),
395395+ ],
396396+397397+ /*
398398+ |--------------------------------------------------------------------------
399399+ | Firehose Configuration
400400+ |--------------------------------------------------------------------------
401401+ |
402402+ | Configuration for Firehose mode (CBOR/CAR events).
403403+ |
404404+ */
405405+406406+ 'firehose' => [
407407+ 'host' => env('SIGNAL_FIREHOSE_HOST', 'bsky.network'),
408408+ ],
409409+410410+ /*
411411+ |--------------------------------------------------------------------------
412412+ | Cursor Storage
413413+ |--------------------------------------------------------------------------
414414+ |
415415+ | Configure how Signal stores cursor positions for resuming after
416416+ | disconnections. Options: 'database', 'redis', 'file'
417417+ |
418418+ */
419419+420420+ 'cursor_storage' => env('SIGNAL_CURSOR_STORAGE', 'database'),
421421+422422+ /*
423423+ |--------------------------------------------------------------------------
424424+ | Redis Configuration
425425+ |--------------------------------------------------------------------------
426426+ |
427427+ | Configuration for Redis cursor storage.
428428+ |
429429+ */
430430+431431+ 'redis' => [
432432+ 'connection' => env('SIGNAL_REDIS_CONNECTION', 'default'),
433433+ 'key_prefix' => env('SIGNAL_REDIS_PREFIX', 'signal:cursor:'),
434434+ ],
435435+436436+ /*
437437+ |--------------------------------------------------------------------------
438438+ | File Configuration
439439+ |--------------------------------------------------------------------------
440440+ |
441441+ | Configuration for file-based cursor storage.
442442+ |
443443+ */
444444+445445+ 'file' => [
446446+ 'path' => env('SIGNAL_FILE_PATH', storage_path('app/signal')),
447447+ ],
448448+449449+ /*
450450+ |--------------------------------------------------------------------------
451451+ | Queue Configuration
452452+ |--------------------------------------------------------------------------
453453+ |
454454+ | Configure queue connection and name for processing events asynchronously.
455455+ |
456456+ */
457457+458458+ 'queue' => [
459459+ 'connection' => env('SIGNAL_QUEUE_CONNECTION', null),
460460+ 'queue' => env('SIGNAL_QUEUE', 'default'),
461461+ ],
462462+463463+ /*
464464+ |--------------------------------------------------------------------------
465465+ | Auto-Discovery
466466+ |--------------------------------------------------------------------------
467467+ |
468468+ | Automatically discover and register Signals from the specified directory.
469469+ |
470470+ */
471471+472472+ 'auto_discovery' => [
473473+ 'enabled' => true,
474474+ 'path' => app_path('Signals'),
475475+ 'namespace' => 'App\\Signals',
476476+ ],
477477+478478+ /*
479479+ |--------------------------------------------------------------------------
480480+ | Manual Signal Registration
481481+ |--------------------------------------------------------------------------
482482+ |
483483+ | Manually register Signals if auto-discovery is disabled.
484484+ |
485485+ */
486486+487487+ 'signals' => [
488488+ // \App\Signals\NewPostSignal::class,
489489+ ],
490490+491491+];
492492+```
493493+494494+## Environment-Specific Configuration
495495+496496+### Development
497497+498498+```env
499499+SIGNAL_MODE=firehose
500500+SIGNAL_CURSOR_STORAGE=file
501501+SIGNAL_QUEUE_CONNECTION=sync
502502+```
503503+504504+**Why:**
505505+- Firehose mode sees all events (comprehensive testing)
506506+- File storage is simple and adequate
507507+- Sync queue processes immediately (easier debugging)
508508+509509+### Staging
510510+511511+```env
512512+SIGNAL_MODE=jetstream
513513+SIGNAL_CURSOR_STORAGE=redis
514514+SIGNAL_QUEUE_CONNECTION=redis
515515+SIGNAL_QUEUE=signal-staging
516516+```
517517+518518+**Why:**
519519+- Jetstream mode matches production
520520+- Redis for performance testing
521521+- Separate queue for staging isolation
522522+523523+### Production
524524+525525+```env
526526+SIGNAL_MODE=jetstream
527527+SIGNAL_CURSOR_STORAGE=database
528528+SIGNAL_QUEUE_CONNECTION=redis
529529+SIGNAL_QUEUE=signal
530530+```
531531+532532+**Why:**
533533+- Jetstream mode for efficiency
534534+- Database storage for reliability
535535+- Redis queues for performance
536536+537537+## Runtime Configuration
538538+539539+Change configuration at runtime:
540540+541541+```php
542542+use SocialDept\Signal\Facades\Signal;
543543+544544+// Override mode
545545+config(['signal.mode' => 'firehose']);
546546+547547+// Override cursor storage
548548+config(['signal.cursor_storage' => 'redis']);
549549+550550+// Start consumer with new config
551551+Signal::start();
552552+```
553553+554554+## Validation
555555+556556+Signal validates configuration on startup:
557557+558558+```bash
559559+php artisan signal:consume
560560+```
561561+562562+**Checks:**
563563+- Mode is valid (`jetstream` or `firehose`)
564564+- Cursor storage driver exists
565565+- Required endpoints are configured
566566+- Queue configuration is valid
567567+568568+**Validation errors prevent consumer from starting.**
569569+570570+## Configuration Helpers
571571+572572+### Check Current Mode
573573+574574+```php
575575+$mode = config('signal.mode'); // 'jetstream' or 'firehose'
576576+```
577577+578578+Or via Facade:
579579+580580+```php
581581+use SocialDept\Signal\Facades\Signal;
582582+583583+$mode = Signal::getMode();
584584+```
585585+586586+### Check Cursor Storage
587587+588588+```php
589589+$storage = config('signal.cursor_storage'); // 'database', 'redis', or 'file'
590590+```
591591+592592+### Check Queue Configuration
593593+594594+```php
595595+$connection = config('signal.queue.connection');
596596+$queue = config('signal.queue.queue');
597597+```
598598+599599+## Best Practices
600600+601601+### Use Environment Variables
602602+603603+Don't hardcode values in config file:
604604+605605+```php
606606+// Good
607607+'mode' => env('SIGNAL_MODE', 'jetstream'),
608608+609609+// Bad
610610+'mode' => 'jetstream',
611611+```
612612+613613+### Separate Staging and Production
614614+615615+Use different queues and storage:
616616+617617+```env
618618+# .env.staging
619619+SIGNAL_QUEUE=signal-staging
620620+621621+# .env.production
622622+SIGNAL_QUEUE=signal-production
623623+```
624624+625625+### Document Custom Configuration
626626+627627+If you change defaults, document why:
628628+629629+```php
630630+// We use Firehose mode because we have custom collections
631631+'mode' => env('SIGNAL_MODE', 'firehose'),
632632+```
633633+634634+### Version Control
635635+636636+Commit `config/signal.php` but not `.env`:
637637+638638+```gitignore
639639+# .gitignore
640640+.env
641641+.env.*
642642+643643+# Commit
644644+config/signal.php
645645+```
646646+647647+## Troubleshooting
648648+649649+### Configuration Not Loading
650650+651651+Clear config cache:
652652+653653+```bash
654654+php artisan config:clear
655655+php artisan config:cache
656656+```
657657+658658+### Environment Variables Not Working
659659+660660+Check `.env` file exists and is readable:
661661+662662+```bash
663663+ls -la .env
664664+```
665665+666666+Restart services after changing `.env`:
667667+668668+```bash
669669+# If using Supervisor
670670+sudo supervisorctl restart signal-consumer:*
671671+```
672672+673673+### Invalid Configuration
674674+675675+Run consumer to see validation errors:
676676+677677+```bash
678678+php artisan signal:consume
679679+```
680680+681681+Signal will display specific errors about misconfiguration.
682682+683683+## Next Steps
684684+685685+- **[Learn about testing →](testing.md)** - Test your configuration
686686+- **[See real-world examples →](examples.md)** - Learn from production configurations
687687+- **[Review queue integration →](queues.md)** - Configure queues optimally
+795
docs/examples.md
···11+# Real-World Examples
22+33+Learn from production-ready Signal examples covering common use cases.
44+55+## Social Media Analytics
66+77+Track engagement metrics across Bluesky.
88+99+```php
1010+<?php
1111+1212+namespace App\Signals;
1313+1414+use App\Models\EngagementMetric;
1515+use SocialDept\Signal\Enums\SignalCommitOperation;
1616+use SocialDept\Signal\Events\SignalEvent;
1717+use SocialDept\Signal\Signals\Signal;
1818+use Illuminate\Support\Facades\DB;
1919+2020+class EngagementTrackerSignal extends Signal
2121+{
2222+ public function eventTypes(): array
2323+ {
2424+ return ['commit'];
2525+ }
2626+2727+ public function collections(): ?array
2828+ {
2929+ return [
3030+ 'app.bsky.feed.post',
3131+ 'app.bsky.feed.like',
3232+ 'app.bsky.feed.repost',
3333+ 'app.bsky.graph.follow',
3434+ ];
3535+ }
3636+3737+ public function operations(): ?array
3838+ {
3939+ return [SignalCommitOperation::Create];
4040+ }
4141+4242+ public function shouldQueue(): bool
4343+ {
4444+ return true;
4545+ }
4646+4747+ public function handle(SignalEvent $event): void
4848+ {
4949+ $collection = $event->getCollection();
5050+ $timestamp = $event->getTimestamp();
5151+5252+ // Increment counter for this hour
5353+ DB::table('engagement_metrics')
5454+ ->updateOrInsert(
5555+ [
5656+ 'collection' => $collection,
5757+ 'hour' => $timestamp->startOfHour(),
5858+ ],
5959+ [
6060+ 'count' => DB::raw('count + 1'),
6161+ 'updated_at' => now(),
6262+ ]
6363+ );
6464+ }
6565+}
6666+```
6767+6868+**Use case:** Build analytics dashboards showing posts/hour, likes/hour, follows/hour.
6969+7070+## Content Moderation
7171+7272+Automatically flag problematic content.
7373+7474+```php
7575+<?php
7676+7777+namespace App\Signals;
7878+7979+use App\Models\FlaggedPost;
8080+use App\Services\ModerationService;
8181+use SocialDept\Signal\Enums\SignalCommitOperation;
8282+use SocialDept\Signal\Events\SignalEvent;
8383+use SocialDept\Signal\Signals\Signal;
8484+8585+class ModerationSignal extends Signal
8686+{
8787+ public function __construct(
8888+ private ModerationService $moderation
8989+ ) {}
9090+9191+ public function eventTypes(): array
9292+ {
9393+ return ['commit'];
9494+ }
9595+9696+ public function collections(): ?array
9797+ {
9898+ return ['app.bsky.feed.post'];
9999+ }
100100+101101+ public function operations(): ?array
102102+ {
103103+ return [SignalCommitOperation::Create];
104104+ }
105105+106106+ public function shouldQueue(): bool
107107+ {
108108+ return true;
109109+ }
110110+111111+ public function queue(): string
112112+ {
113113+ return 'moderation';
114114+ }
115115+116116+ public function handle(SignalEvent $event): void
117117+ {
118118+ $record = $event->getRecord();
119119+120120+ if (!isset($record->text)) {
121121+ return;
122122+ }
123123+124124+ $result = $this->moderation->analyze($record->text);
125125+126126+ if ($result->needsReview) {
127127+ FlaggedPost::create([
128128+ 'did' => $event->did,
129129+ 'rkey' => $event->commit->rkey,
130130+ 'text' => $record->text,
131131+ 'reason' => $result->reason,
132132+ 'confidence' => $result->confidence,
133133+ 'flagged_at' => now(),
134134+ ]);
135135+ }
136136+ }
137137+138138+ public function failed(SignalEvent $event, \Throwable $exception): void
139139+ {
140140+ Log::error('Moderation signal failed', [
141141+ 'did' => $event->did,
142142+ 'error' => $exception->getMessage(),
143143+ ]);
144144+ }
145145+}
146146+```
147147+148148+**Use case:** Automated content moderation with human review queue.
149149+150150+## User Activity Feed
151151+152152+Build a personalized activity feed.
153153+154154+```php
155155+<?php
156156+157157+namespace App\Signals;
158158+159159+use App\Models\Activity;
160160+use App\Models\User;
161161+use SocialDept\Signal\Enums\SignalCommitOperation;
162162+use SocialDept\Signal\Events\SignalEvent;
163163+use SocialDept\Signal\Signals\Signal;
164164+165165+class ActivityFeedSignal extends Signal
166166+{
167167+ public function eventTypes(): array
168168+ {
169169+ return ['commit'];
170170+ }
171171+172172+ public function collections(): ?array
173173+ {
174174+ return [
175175+ 'app.bsky.feed.post',
176176+ 'app.bsky.feed.like',
177177+ 'app.bsky.feed.repost',
178178+ ];
179179+ }
180180+181181+ public function operations(): ?array
182182+ {
183183+ return [SignalCommitOperation::Create];
184184+ }
185185+186186+ public function shouldQueue(): bool
187187+ {
188188+ return true;
189189+ }
190190+191191+ public function handle(SignalEvent $event): void
192192+ {
193193+ // Check if we're tracking this user
194194+ $user = User::where('did', $event->did)->first();
195195+196196+ if (!$user) {
197197+ return;
198198+ }
199199+200200+ // Check if any followers want to see this
201201+ $followerIds = $user->followers()->pluck('id');
202202+203203+ if ($followerIds->isEmpty()) {
204204+ return;
205205+ }
206206+207207+ $collection = $event->getCollection();
208208+209209+ // Create activity for each follower's feed
210210+ foreach ($followerIds as $followerId) {
211211+ Activity::create([
212212+ 'user_id' => $followerId,
213213+ 'actor_did' => $event->did,
214214+ 'type' => $this->getActivityType($collection),
215215+ 'data' => $event->toArray(),
216216+ 'created_at' => $event->getTimestamp(),
217217+ ]);
218218+ }
219219+ }
220220+221221+ private function getActivityType(string $collection): string
222222+ {
223223+ return match ($collection) {
224224+ 'app.bsky.feed.post' => 'post',
225225+ 'app.bsky.feed.like' => 'like',
226226+ 'app.bsky.feed.repost' => 'repost',
227227+ default => 'unknown',
228228+ };
229229+ }
230230+}
231231+```
232232+233233+**Use case:** Show users activity from people they follow.
234234+235235+## Real-Time Notifications
236236+237237+Send notifications for mentions and interactions.
238238+239239+```php
240240+<?php
241241+242242+namespace App\Signals;
243243+244244+use App\Models\User;
245245+use App\Notifications\MentionedInPost;
246246+use SocialDept\Signal\Enums\SignalCommitOperation;
247247+use SocialDept\Signal\Events\SignalEvent;
248248+use SocialDept\Signal\Signals\Signal;
249249+250250+class MentionNotificationSignal extends Signal
251251+{
252252+ public function eventTypes(): array
253253+ {
254254+ return ['commit'];
255255+ }
256256+257257+ public function collections(): ?array
258258+ {
259259+ return ['app.bsky.feed.post'];
260260+ }
261261+262262+ public function operations(): ?array
263263+ {
264264+ return [SignalCommitOperation::Create];
265265+ }
266266+267267+ public function shouldQueue(): bool
268268+ {
269269+ return true;
270270+ }
271271+272272+ public function handle(SignalEvent $event): void
273273+ {
274274+ $record = $event->getRecord();
275275+276276+ if (!isset($record->facets)) {
277277+ return;
278278+ }
279279+280280+ // Extract mentions from facets
281281+ $mentions = collect($record->facets)
282282+ ->filter(fn($facet) => isset($facet->features))
283283+ ->flatMap(fn($facet) => $facet->features)
284284+ ->filter(fn($feature) => $feature->{'$type'} === 'app.bsky.richtext.facet#mention')
285285+ ->pluck('did')
286286+ ->unique();
287287+288288+ foreach ($mentions as $mentionedDid) {
289289+ $user = User::where('did', $mentionedDid)->first();
290290+291291+ if ($user) {
292292+ $user->notify(new MentionedInPost(
293293+ authorDid: $event->did,
294294+ text: $record->text ?? '',
295295+ uri: "at://{$event->did}/app.bsky.feed.post/{$event->commit->rkey}"
296296+ ));
297297+ }
298298+ }
299299+ }
300300+}
301301+```
302302+303303+**Use case:** Real-time notifications when users are mentioned.
304304+305305+## Follow Tracker
306306+307307+Track follow relationships and send notifications.
308308+309309+```php
310310+<?php
311311+312312+namespace App\Signals;
313313+314314+use App\Models\Follow;
315315+use App\Models\User;
316316+use App\Notifications\NewFollower;
317317+use SocialDept\Signal\Enums\SignalCommitOperation;
318318+use SocialDept\Signal\Events\SignalEvent;
319319+use SocialDept\Signal\Signals\Signal;
320320+321321+class FollowTrackerSignal extends Signal
322322+{
323323+ public function eventTypes(): array
324324+ {
325325+ return ['commit'];
326326+ }
327327+328328+ public function collections(): ?array
329329+ {
330330+ return ['app.bsky.graph.follow'];
331331+ }
332332+333333+ public function operations(): ?array
334334+ {
335335+ return [
336336+ SignalCommitOperation::Create,
337337+ SignalCommitOperation::Delete,
338338+ ];
339339+ }
340340+341341+ public function shouldQueue(): bool
342342+ {
343343+ return true;
344344+ }
345345+346346+ public function handle(SignalEvent $event): void
347347+ {
348348+ $record = $event->getRecord();
349349+ $operation = $event->getOperation();
350350+351351+ if ($operation === SignalCommitOperation::Create) {
352352+ $this->handleNewFollow($event, $record);
353353+ } else {
354354+ $this->handleUnfollow($event);
355355+ }
356356+ }
357357+358358+ private function handleNewFollow(SignalEvent $event, object $record): void
359359+ {
360360+ Follow::create([
361361+ 'follower_did' => $event->did,
362362+ 'following_did' => $record->subject,
363363+ 'created_at' => $record->createdAt ?? now(),
364364+ ]);
365365+366366+ // Notify the followed user
367367+ $followedUser = User::where('did', $record->subject)->first();
368368+369369+ if ($followedUser) {
370370+ $followedUser->notify(new NewFollower($event->did));
371371+ }
372372+ }
373373+374374+ private function handleUnfollow(SignalEvent $event): void
375375+ {
376376+ Follow::where('follower_did', $event->did)
377377+ ->where('rkey', $event->commit->rkey)
378378+ ->delete();
379379+ }
380380+}
381381+```
382382+383383+**Use case:** Track follows and notify users of new followers.
384384+385385+## Search Indexer
386386+387387+Index posts for full-text search.
388388+389389+```php
390390+<?php
391391+392392+namespace App\Signals;
393393+394394+use App\Models\Post;
395395+use Laravel\Scout\Searchable;
396396+use SocialDept\Signal\Enums\SignalCommitOperation;
397397+use SocialDept\Signal\Events\SignalEvent;
398398+use SocialDept\Signal\Signals\Signal;
399399+400400+class SearchIndexerSignal extends Signal
401401+{
402402+ public function eventTypes(): array
403403+ {
404404+ return ['commit'];
405405+ }
406406+407407+ public function collections(): ?array
408408+ {
409409+ return ['app.bsky.feed.post'];
410410+ }
411411+412412+ public function shouldQueue(): bool
413413+ {
414414+ return true;
415415+ }
416416+417417+ public function queue(): string
418418+ {
419419+ return 'indexing';
420420+ }
421421+422422+ public function handle(SignalEvent $event): void
423423+ {
424424+ $operation = $event->getOperation();
425425+426426+ match ($operation) {
427427+ SignalCommitOperation::Create,
428428+ SignalCommitOperation::Update => $this->indexPost($event),
429429+ SignalCommitOperation::Delete => $this->deletePost($event),
430430+ };
431431+ }
432432+433433+ private function indexPost(SignalEvent $event): void
434434+ {
435435+ $record = $event->getRecord();
436436+437437+ $post = Post::updateOrCreate(
438438+ [
439439+ 'did' => $event->did,
440440+ 'rkey' => $event->commit->rkey,
441441+ ],
442442+ [
443443+ 'text' => $record->text ?? '',
444444+ 'created_at' => $record->createdAt ?? now(),
445445+ 'indexed_at' => now(),
446446+ ]
447447+ );
448448+449449+ // Scout automatically indexes
450450+ $post->searchable();
451451+ }
452452+453453+ private function deletePost(SignalEvent $event): void
454454+ {
455455+ $post = Post::where('did', $event->did)
456456+ ->where('rkey', $event->commit->rkey)
457457+ ->first();
458458+459459+ if ($post) {
460460+ $post->unsearchable();
461461+ $post->delete();
462462+ }
463463+ }
464464+}
465465+```
466466+467467+**Use case:** Full-text search across all Bluesky posts.
468468+469469+## Trend Detection
470470+471471+Identify trending topics and hashtags.
472472+473473+```php
474474+<?php
475475+476476+namespace App\Signals;
477477+478478+use App\Models\TrendingTopic;
479479+use Illuminate\Support\Facades\Cache;
480480+use SocialDept\Signal\Enums\SignalCommitOperation;
481481+use SocialDept\Signal\Events\SignalEvent;
482482+use SocialDept\Signal\Signals\Signal;
483483+484484+class TrendDetectionSignal extends Signal
485485+{
486486+ public function eventTypes(): array
487487+ {
488488+ return ['commit'];
489489+ }
490490+491491+ public function collections(): ?array
492492+ {
493493+ return ['app.bsky.feed.post'];
494494+ }
495495+496496+ public function operations(): ?array
497497+ {
498498+ return [SignalCommitOperation::Create];
499499+ }
500500+501501+ public function shouldQueue(): bool
502502+ {
503503+ return true;
504504+ }
505505+506506+ public function handle(SignalEvent $event): void
507507+ {
508508+ $record = $event->getRecord();
509509+510510+ if (!isset($record->text)) {
511511+ return;
512512+ }
513513+514514+ // Extract hashtags
515515+ preg_match_all('/#(\w+)/', $record->text, $matches);
516516+517517+ foreach ($matches[1] as $hashtag) {
518518+ $this->incrementHashtag($hashtag);
519519+ }
520520+ }
521521+522522+ private function incrementHashtag(string $hashtag): void
523523+ {
524524+ $key = "trending:hashtag:{$hashtag}";
525525+526526+ // Increment counter (expires after 1 hour)
527527+ $count = Cache::increment($key, 1);
528528+529529+ if (!Cache::has($key)) {
530530+ Cache::put($key, 1, now()->addHour());
531531+ }
532532+533533+ // Update trending topics if threshold reached
534534+ if ($count > 100) {
535535+ TrendingTopic::updateOrCreate(
536536+ ['hashtag' => $hashtag],
537537+ ['count' => $count, 'updated_at' => now()]
538538+ );
539539+ }
540540+ }
541541+}
542542+```
543543+544544+**Use case:** Identify trending hashtags and topics in real-time.
545545+546546+## Custom AppView
547547+548548+Index custom collections for your AppView.
549549+550550+```php
551551+<?php
552552+553553+namespace App\Signals;
554554+555555+use App\Models\Publication;
556556+use SocialDept\Signal\Enums\SignalCommitOperation;
557557+use SocialDept\Signal\Events\SignalEvent;
558558+use SocialDept\Signal\Signals\Signal;
559559+560560+class PublicationIndexerSignal extends Signal
561561+{
562562+ public function eventTypes(): array
563563+ {
564564+ return ['commit'];
565565+ }
566566+567567+ public function collections(): ?array
568568+ {
569569+ return [
570570+ 'app.offprint.beta.publication',
571571+ 'app.offprint.beta.post',
572572+ ];
573573+ }
574574+575575+ public function shouldQueue(): bool
576576+ {
577577+ return true;
578578+ }
579579+580580+ public function handle(SignalEvent $event): void
581581+ {
582582+ $collection = $event->getCollection();
583583+ $operation = $event->getOperation();
584584+585585+ if ($collection === 'app.offprint.beta.publication') {
586586+ $this->handlePublication($event, $operation);
587587+ } else {
588588+ $this->handlePost($event, $operation);
589589+ }
590590+ }
591591+592592+ private function handlePublication(SignalEvent $event, SignalCommitOperation $operation): void
593593+ {
594594+ if ($operation === SignalCommitOperation::Delete) {
595595+ Publication::where('did', $event->did)
596596+ ->where('rkey', $event->commit->rkey)
597597+ ->delete();
598598+ return;
599599+ }
600600+601601+ $record = $event->getRecord();
602602+603603+ Publication::updateOrCreate(
604604+ [
605605+ 'did' => $event->did,
606606+ 'rkey' => $event->commit->rkey,
607607+ ],
608608+ [
609609+ 'title' => $record->title ?? '',
610610+ 'description' => $record->description ?? null,
611611+ 'created_at' => $record->createdAt ?? now(),
612612+ ]
613613+ );
614614+ }
615615+616616+ private function handlePost(SignalEvent $event, SignalCommitOperation $operation): void
617617+ {
618618+ // Handle custom post records
619619+ }
620620+}
621621+```
622622+623623+**Use case:** Build AT Protocol AppViews with custom collections.
624624+625625+## Rate-Limited API Integration
626626+627627+Integrate with external APIs respecting rate limits.
628628+629629+```php
630630+<?php
631631+632632+namespace App\Signals;
633633+634634+use App\Services\ExternalAPIService;
635635+use Illuminate\Support\Facades\RateLimiter;
636636+use SocialDept\Signal\Events\SignalEvent;
637637+use SocialDept\Signal\Signals\Signal;
638638+639639+class APIIntegrationSignal extends Signal
640640+{
641641+ public function __construct(
642642+ private ExternalAPIService $api
643643+ ) {}
644644+645645+ public function eventTypes(): array
646646+ {
647647+ return ['commit'];
648648+ }
649649+650650+ public function collections(): ?array
651651+ {
652652+ return ['app.bsky.feed.post'];
653653+ }
654654+655655+ public function shouldQueue(): bool
656656+ {
657657+ return true;
658658+ }
659659+660660+ public function handle(SignalEvent $event): void
661661+ {
662662+ $record = $event->getRecord();
663663+664664+ // Rate limit: 100 calls per minute
665665+ $executed = RateLimiter::attempt(
666666+ 'external-api',
667667+ $perMinute = 100,
668668+ function () use ($event, $record) {
669669+ $this->api->sendPost([
670670+ 'author' => $event->did,
671671+ 'text' => $record->text ?? '',
672672+ 'timestamp' => $event->getTimestamp(),
673673+ ]);
674674+ }
675675+ );
676676+677677+ if (!$executed) {
678678+ // Re-queue for later
679679+ dispatch(fn() => $this->handle($event))
680680+ ->delay(now()->addMinutes(1));
681681+ }
682682+ }
683683+}
684684+```
685685+686686+**Use case:** Mirror content to external platforms with rate limiting.
687687+688688+## Multi-Collection Analytics
689689+690690+Track engagement across multiple collection types.
691691+692692+```php
693693+<?php
694694+695695+namespace App\Signals;
696696+697697+use App\Models\UserMetrics;
698698+use SocialDept\Signal\Enums\SignalCommitOperation;
699699+use SocialDept\Signal\Events\SignalEvent;
700700+use SocialDept\Signal\Signals\Signal;
701701+702702+class UserMetricsSignal extends Signal
703703+{
704704+ public function eventTypes(): array
705705+ {
706706+ return ['commit'];
707707+ }
708708+709709+ public function collections(): ?array
710710+ {
711711+ return ['app.bsky.feed.*', 'app.bsky.graph.*'];
712712+ }
713713+714714+ public function operations(): ?array
715715+ {
716716+ return [SignalCommitOperation::Create];
717717+ }
718718+719719+ public function shouldQueue(): bool
720720+ {
721721+ return true;
722722+ }
723723+724724+ public function handle(SignalEvent $event): void
725725+ {
726726+ $collection = $event->getCollection();
727727+728728+ $metrics = UserMetrics::firstOrCreate(
729729+ ['did' => $event->did],
730730+ ['total_posts' => 0, 'total_likes' => 0, 'total_follows' => 0]
731731+ );
732732+733733+ match ($collection) {
734734+ 'app.bsky.feed.post' => $metrics->increment('total_posts'),
735735+ 'app.bsky.feed.like' => $metrics->increment('total_likes'),
736736+ 'app.bsky.graph.follow' => $metrics->increment('total_follows'),
737737+ default => null,
738738+ };
739739+740740+ $metrics->touch('last_activity_at');
741741+ }
742742+}
743743+```
744744+745745+**Use case:** User activity metrics and leaderboards.
746746+747747+## Performance Tips
748748+749749+### Batch Database Operations
750750+751751+```php
752752+public function handle(SignalEvent $event): void
753753+{
754754+ // Bad - individual inserts
755755+ Post::create([...]);
756756+757757+ // Good - batch inserts
758758+ $posts = Cache::get('pending_posts', []);
759759+ $posts[] = [...];
760760+761761+ if (count($posts) >= 100) {
762762+ Post::insert($posts);
763763+ Cache::forget('pending_posts');
764764+ } else {
765765+ Cache::put('pending_posts', $posts, now()->addMinutes(5));
766766+ }
767767+}
768768+```
769769+770770+### Use Queues for Heavy Operations
771771+772772+```php
773773+public function shouldQueue(): bool
774774+{
775775+ // Queue if operation takes > 100ms
776776+ return true;
777777+}
778778+```
779779+780780+### Add Indexes for Filtering
781781+782782+```php
783783+// Migration for fast lookups
784784+Schema::table('posts', function (Blueprint $table) {
785785+ $table->index(['did', 'rkey']);
786786+ $table->index('created_at');
787787+});
788788+```
789789+790790+## Next Steps
791791+792792+- **[Review signal architecture →](signals.md)** - Understand Signal structure
793793+- **[Learn about filtering →](filtering.md)** - Master event filtering
794794+- **[Explore queue integration →](queues.md)** - Build high-performance Signals
795795+- **[Configure your setup →](configuration.md)** - Optimize configuration
+704
docs/filtering.md
···11+# Filtering Events
22+33+Filtering is how you control which events your Signals process. Signal provides multiple layers of filtering to help you target exactly the events you care about.
44+55+## Why Filter?
66+77+The AT Protocol generates millions of events per hour. Without filtering:
88+99+- Your Signals would process every event (slow and expensive)
1010+- Your database would fill with irrelevant data
1111+- Your queues would be overwhelmed
1212+- Your costs would skyrocket
1313+1414+Filtering lets you focus on what matters.
1515+1616+## Filter Layers
1717+1818+Signal provides four filtering layers, applied in order:
1919+2020+1. **Event Type Filtering** - Which kind of events (commit, identity, account)
2121+2. **Collection Filtering** - Which AT Protocol collections
2222+3. **Operation Filtering** - Which operations (create, update, delete)
2323+4. **DID Filtering** - Which users
2424+5. **Custom Filtering** - Your own logic
2525+2626+## Event Type Filtering
2727+2828+The most basic filter - required for all Signals.
2929+3030+### Available Event Types
3131+3232+```php
3333+use SocialDept\Signal\Enums\SignalEventType;
3434+3535+public function eventTypes(): array
3636+{
3737+ return [SignalEventType::Commit]; // Most common
3838+ // Or: return ['commit'];
3939+}
4040+```
4141+4242+**Three event types:**
4343+4444+| Type | Description | Use Cases |
4545+|------------|--------------------|----------------------------------------|
4646+| `commit` | Repository changes | Posts, likes, follows, profile updates |
4747+| `identity` | Handle changes | Username updates, account migrations |
4848+| `account` | Account status | Deactivation, suspension |
4949+5050+### Multiple Event Types
5151+5252+Listen to multiple types in one Signal:
5353+5454+```php
5555+public function eventTypes(): array
5656+{
5757+ return [
5858+ SignalEventType::Commit,
5959+ SignalEventType::Identity,
6060+ ];
6161+}
6262+```
6363+6464+Then check the type in your handler:
6565+6666+```php
6767+public function handle(SignalEvent $event): void
6868+{
6969+ if ($event->isCommit()) {
7070+ $this->handleCommit($event);
7171+ }
7272+7373+ if ($event->isIdentity()) {
7474+ $this->handleIdentity($event);
7575+ }
7676+}
7777+```
7878+7979+## Collection Filtering
8080+8181+Collections represent different types of data in the AT Protocol.
8282+8383+### Basic Collection Filter
8484+8585+```php
8686+public function collections(): ?array
8787+{
8888+ return ['app.bsky.feed.post'];
8989+}
9090+```
9191+9292+### No Filter (All Collections)
9393+9494+Return `null` to process all collections:
9595+9696+```php
9797+public function collections(): ?array
9898+{
9999+ return null; // Handle everything
100100+}
101101+```
102102+103103+### Multiple Collections
104104+105105+```php
106106+public function collections(): ?array
107107+{
108108+ return [
109109+ 'app.bsky.feed.post',
110110+ 'app.bsky.feed.like',
111111+ 'app.bsky.feed.repost',
112112+ ];
113113+}
114114+```
115115+116116+### Wildcard Patterns
117117+118118+Use `*` to match multiple collections:
119119+120120+```php
121121+public function collections(): ?array
122122+{
123123+ return ['app.bsky.feed.*'];
124124+}
125125+```
126126+127127+**This matches:**
128128+- `app.bsky.feed.post`
129129+- `app.bsky.feed.like`
130130+- `app.bsky.feed.repost`
131131+- Any other `app.bsky.feed.*` collection
132132+133133+### Common Collection Patterns
134134+135135+| Pattern | Matches | Use Case |
136136+|--------------------|-------------------------|------------------------|
137137+| `app.bsky.feed.*` | All feed interactions | Posts, likes, reposts |
138138+| `app.bsky.graph.*` | All social graph | Follows, blocks, mutes |
139139+| `app.bsky.actor.*` | All profile changes | Profile updates |
140140+| `app.bsky.*` | All Bluesky collections | Everything Bluesky |
141141+| `app.yourapp.*` | Your custom collections | Custom AppView |
142142+143143+### Mixing Exact and Wildcards
144144+145145+Combine exact matches with wildcards:
146146+147147+```php
148148+public function collections(): ?array
149149+{
150150+ return [
151151+ 'app.bsky.feed.post', // Exact: only posts
152152+ 'app.bsky.graph.*', // Wildcard: all graph events
153153+ 'app.myapp.custom.record', // Exact: custom collection
154154+ ];
155155+}
156156+```
157157+158158+### Standard Bluesky Collections
159159+160160+**Feed Collections** (`app.bsky.feed.*`):
161161+- `app.bsky.feed.post` - Posts (text, images, videos)
162162+- `app.bsky.feed.like` - Likes on posts
163163+- `app.bsky.feed.repost` - Reposts (shares)
164164+- `app.bsky.feed.threadgate` - Thread reply controls
165165+- `app.bsky.feed.generator` - Custom feed generators
166166+167167+**Graph Collections** (`app.bsky.graph.*`):
168168+- `app.bsky.graph.follow` - Follow relationships
169169+- `app.bsky.graph.block` - Blocked users
170170+- `app.bsky.graph.list` - User lists
171171+- `app.bsky.graph.listitem` - List memberships
172172+- `app.bsky.graph.listblock` - List blocks
173173+174174+**Actor Collections** (`app.bsky.actor.*`):
175175+- `app.bsky.actor.profile` - User profiles
176176+177177+**Labeler Collections** (`app.bsky.labeler.*`):
178178+- `app.bsky.labeler.service` - Labeler services
179179+180180+### Important: Jetstream vs Firehose Filtering
181181+182182+**Jetstream Mode:**
183183+- Exact collection names are sent to server for filtering (efficient)
184184+- Wildcards work client-side only (you receive more data)
185185+186186+**Firehose Mode:**
187187+- All filtering is client-side
188188+- Wildcards work normally (no difference in data received)
189189+190190+[Learn more about modes →](modes.md)
191191+192192+### Custom Collections (AppViews)
193193+194194+Filter your own custom collections:
195195+196196+```php
197197+public function collections(): ?array
198198+{
199199+ return [
200200+ 'app.offprint.beta.publication',
201201+ 'app.offprint.beta.post',
202202+ ];
203203+}
204204+```
205205+206206+## Operation Filtering
207207+208208+Filter by operation type (only applies to commit events).
209209+210210+### Available Operations
211211+212212+```php
213213+use SocialDept\Signal\Enums\SignalCommitOperation;
214214+215215+public function operations(): ?array
216216+{
217217+ return [SignalCommitOperation::Create];
218218+ // Or: return ['create'];
219219+}
220220+```
221221+222222+**Three operation types:**
223223+224224+| Operation | Description | Example |
225225+|-----------|------------------|-----------------|
226226+| `create` | New records | Creating a post |
227227+| `update` | Modified records | Editing a post |
228228+| `delete` | Removed records | Deleting a post |
229229+230230+### No Filter (All Operations)
231231+232232+```php
233233+public function operations(): ?array
234234+{
235235+ return null; // Handle all operations
236236+}
237237+```
238238+239239+### Multiple Operations
240240+241241+```php
242242+public function operations(): ?array
243243+{
244244+ return [
245245+ SignalCommitOperation::Create,
246246+ SignalCommitOperation::Update,
247247+ ];
248248+ // Or: return ['create', 'update'];
249249+}
250250+```
251251+252252+### Common Patterns
253253+254254+**Only track new content:**
255255+```php
256256+public function operations(): ?array
257257+{
258258+ return [SignalCommitOperation::Create];
259259+}
260260+```
261261+262262+**Track modifications:**
263263+```php
264264+public function operations(): ?array
265265+{
266266+ return [SignalCommitOperation::Update];
267267+}
268268+```
269269+270270+**Cleanup on deletions:**
271271+```php
272272+public function operations(): ?array
273273+{
274274+ return [SignalCommitOperation::Delete];
275275+}
276276+```
277277+278278+### Checking Operations in Handler
279279+280280+You can also check operation type in your handler:
281281+282282+```php
283283+public function handle(SignalEvent $event): void
284284+{
285285+ $operation = $event->getOperation();
286286+287287+ // Using enum
288288+ if ($operation === SignalCommitOperation::Create) {
289289+ $this->createRecord($event);
290290+ }
291291+292292+ // Using commit helper
293293+ if ($event->commit->isCreate()) {
294294+ $this->createRecord($event);
295295+ }
296296+297297+ if ($event->commit->isUpdate()) {
298298+ $this->updateRecord($event);
299299+ }
300300+301301+ if ($event->commit->isDelete()) {
302302+ $this->deleteRecord($event);
303303+ }
304304+}
305305+```
306306+307307+## DID Filtering
308308+309309+Filter events by specific users (DIDs).
310310+311311+### Basic DID Filter
312312+313313+```php
314314+public function dids(): ?array
315315+{
316316+ return [
317317+ 'did:plc:z72i7hdynmk6r22z27h6tvur',
318318+ ];
319319+}
320320+```
321321+322322+### No Filter (All Users)
323323+324324+```php
325325+public function dids(): ?array
326326+{
327327+ return null; // Handle all users
328328+}
329329+```
330330+331331+### Multiple DIDs
332332+333333+```php
334334+public function dids(): ?array
335335+{
336336+ return [
337337+ 'did:plc:z72i7hdynmk6r22z27h6tvur',
338338+ 'did:plc:ragtjsm2j2vknwkz3zp4oxrd',
339339+ ];
340340+}
341341+```
342342+343343+### Use Cases
344344+345345+**Monitor specific accounts:**
346346+```php
347347+// Track posts from specific content creators
348348+public function collections(): ?array
349349+{
350350+ return ['app.bsky.feed.post'];
351351+}
352352+353353+public function dids(): ?array
354354+{
355355+ return [
356356+ 'did:plc:z72i7hdynmk6r22z27h6tvur', // Creator 1
357357+ 'did:plc:ragtjsm2j2vknwkz3zp4oxrd', // Creator 2
358358+ ];
359359+}
360360+```
361361+362362+**Dynamic DID filtering:**
363363+```php
364364+use App\Models\MonitoredAccount;
365365+366366+public function dids(): ?array
367367+{
368368+ return MonitoredAccount::pluck('did')->toArray();
369369+}
370370+```
371371+372372+## Custom Filtering
373373+374374+Implement complex filtering logic with `shouldHandle()`.
375375+376376+### Basic Custom Filter
377377+378378+```php
379379+public function shouldHandle(SignalEvent $event): bool
380380+{
381381+ // Only handle posts with images
382382+ if ($event->isCommit() && $event->getCollection() === 'app.bsky.feed.post') {
383383+ $record = $event->getRecord();
384384+ return isset($record->embed);
385385+ }
386386+387387+ return true;
388388+}
389389+```
390390+391391+### Advanced Examples
392392+393393+**Filter by text content:**
394394+```php
395395+public function shouldHandle(SignalEvent $event): bool
396396+{
397397+ $record = $event->getRecord();
398398+399399+ if (!isset($record->text)) {
400400+ return false;
401401+ }
402402+403403+ // Only handle posts mentioning "Laravel"
404404+ return str_contains($record->text, 'Laravel');
405405+}
406406+```
407407+408408+**Filter by language:**
409409+```php
410410+public function shouldHandle(SignalEvent $event): bool
411411+{
412412+ $record = $event->getRecord();
413413+414414+ // Only handle English posts
415415+ return ($record->langs[0] ?? null) === 'en';
416416+}
417417+```
418418+419419+**Filter by engagement:**
420420+```php
421421+use App\Services\EngagementCalculator;
422422+423423+public function shouldHandle(SignalEvent $event): bool
424424+{
425425+ $engagement = EngagementCalculator::calculate($event);
426426+427427+ // Only handle high-engagement content
428428+ return $engagement > 100;
429429+}
430430+```
431431+432432+**Time-based filtering:**
433433+```php
434434+public function shouldHandle(SignalEvent $event): bool
435435+{
436436+ $timestamp = $event->getTimestamp();
437437+438438+ // Only handle events from the last hour
439439+ return $timestamp->isAfter(now()->subHour());
440440+}
441441+```
442442+443443+## Combining Filters
444444+445445+Stack multiple filter layers for precise targeting:
446446+447447+```php
448448+class HighEngagementPostsSignal extends Signal
449449+{
450450+ // Layer 1: Event type
451451+ public function eventTypes(): array
452452+ {
453453+ return ['commit'];
454454+ }
455455+456456+ // Layer 2: Collection
457457+ public function collections(): ?array
458458+ {
459459+ return ['app.bsky.feed.post'];
460460+ }
461461+462462+ // Layer 3: Operation
463463+ public function operations(): ?array
464464+ {
465465+ return [SignalCommitOperation::Create];
466466+ }
467467+468468+ // Layer 4: Custom logic
469469+ public function shouldHandle(SignalEvent $event): bool
470470+ {
471471+ $record = $event->getRecord();
472472+473473+ // Must have text
474474+ if (!isset($record->text)) {
475475+ return false;
476476+ }
477477+478478+ // Must be longer than 100 characters
479479+ if (strlen($record->text) < 100) {
480480+ return false;
481481+ }
482482+483483+ // Must have media
484484+ if (!isset($record->embed)) {
485485+ return false;
486486+ }
487487+488488+ return true;
489489+ }
490490+491491+ public function handle(SignalEvent $event): void
492492+ {
493493+ // Only high-quality posts make it here
494494+ }
495495+}
496496+```
497497+498498+## Performance Considerations
499499+500500+### Server-Side vs Client-Side Filtering
501501+502502+**Jetstream Mode (Server-Side):**
503503+- Collections filter applied on server (efficient)
504504+- Only receives matching events
505505+- Lower bandwidth usage
506506+507507+```php
508508+// These collections are sent to Jetstream server
509509+public function collections(): ?array
510510+{
511511+ return ['app.bsky.feed.post', 'app.bsky.feed.like'];
512512+}
513513+```
514514+515515+**Firehose Mode (Client-Side):**
516516+- All filtering happens in your application
517517+- Receives all events (higher bandwidth)
518518+- More control but higher cost
519519+520520+[Learn more about modes →](modes.md)
521521+522522+### Filter Early
523523+524524+Apply the most restrictive filters first:
525525+526526+```php
527527+// Good - filters early
528528+public function eventTypes(): array
529529+{
530530+ return ['commit']; // Narrows to commits only
531531+}
532532+533533+public function collections(): ?array
534534+{
535535+ return ['app.bsky.feed.post']; // Further narrows to posts
536536+}
537537+538538+// Less ideal - too broad
539539+public function eventTypes(): array
540540+{
541541+ return ['commit', 'identity', 'account']; // Too many events
542542+}
543543+544544+public function shouldHandle(SignalEvent $event): bool
545545+{
546546+ // Filtering everything in custom logic (expensive)
547547+ return $event->isCommit() && $event->getCollection() === 'app.bsky.feed.post';
548548+}
549549+```
550550+551551+### Avoid Heavy Logic in shouldHandle()
552552+553553+Keep custom filtering lightweight:
554554+555555+```php
556556+// Good - lightweight checks
557557+public function shouldHandle(SignalEvent $event): bool
558558+{
559559+ $record = $event->getRecord();
560560+ return isset($record->text) && strlen($record->text) > 10;
561561+}
562562+563563+// Less ideal - heavy database queries
564564+public function shouldHandle(SignalEvent $event): bool
565565+{
566566+ // Database query on every event (slow!)
567567+ return User::where('did', $event->did)->exists();
568568+}
569569+```
570570+571571+If you need heavy logic, use queues:
572572+573573+```php
574574+public function shouldQueue(): bool
575575+{
576576+ return true; // Move heavy work to queue
577577+}
578578+```
579579+580580+## Common Filter Patterns
581581+582582+### Track All Activity from Specific Users
583583+584584+```php
585585+public function eventTypes(): array
586586+{
587587+ return ['commit'];
588588+}
589589+590590+public function dids(): ?array
591591+{
592592+ return [
593593+ 'did:plc:z72i7hdynmk6r22z27h6tvur',
594594+ ];
595595+}
596596+```
597597+598598+### Monitor All Feed Activity
599599+600600+```php
601601+public function eventTypes(): array
602602+{
603603+ return ['commit'];
604604+}
605605+606606+public function collections(): ?array
607607+{
608608+ return ['app.bsky.feed.*'];
609609+}
610610+```
611611+612612+### Track Only New Posts
613613+614614+```php
615615+public function eventTypes(): array
616616+{
617617+ return ['commit'];
618618+}
619619+620620+public function collections(): ?array
621621+{
622622+ return ['app.bsky.feed.post'];
623623+}
624624+625625+public function operations(): ?array
626626+{
627627+ return [SignalCommitOperation::Create];
628628+}
629629+```
630630+631631+### Monitor Content Deletions
632632+633633+```php
634634+public function eventTypes(): array
635635+{
636636+ return ['commit'];
637637+}
638638+639639+public function operations(): ?array
640640+{
641641+ return [SignalCommitOperation::Delete];
642642+}
643643+```
644644+645645+### Track Profile Changes
646646+647647+```php
648648+public function eventTypes(): array
649649+{
650650+ return ['commit'];
651651+}
652652+653653+public function collections(): ?array
654654+{
655655+ return ['app.bsky.actor.profile'];
656656+}
657657+```
658658+659659+### Monitor Handle Changes
660660+661661+```php
662662+public function eventTypes(): array
663663+{
664664+ return ['identity'];
665665+}
666666+```
667667+668668+## Debugging Filters
669669+670670+### Log What's Being Filtered
671671+672672+```php
673673+public function shouldHandle(SignalEvent $event): bool
674674+{
675675+ $shouldHandle = $this->myCustomLogic($event);
676676+677677+ if (!$shouldHandle) {
678678+ Log::debug('Event filtered out', [
679679+ 'signal' => static::class,
680680+ 'did' => $event->did,
681681+ 'collection' => $event->getCollection(),
682682+ 'reason' => 'Failed custom logic',
683683+ ]);
684684+ }
685685+686686+ return $shouldHandle;
687687+}
688688+```
689689+690690+### Test Your Filters
691691+692692+```bash
693693+php artisan signal:test YourSignal
694694+```
695695+696696+This runs your Signal with sample data to verify filtering works correctly.
697697+698698+[Learn more about testing →](testing.md)
699699+700700+## Next Steps
701701+702702+- **[Understand Jetstream vs Firehose →](modes.md)** - Choose the right mode for your filters
703703+- **[Learn about queue integration →](queues.md)** - Handle high-volume filtered events
704704+- **[See real-world examples →](examples.md)** - Learn from production filter patterns
+188
docs/installation.md
···11+# Installation
22+33+Signal is designed to be installed quickly and easily in any Laravel 11+ application.
44+55+## Requirements
66+77+Before installing Signal, ensure your environment meets these requirements:
88+99+- **PHP 8.2 or higher**
1010+- **Laravel 11.0 or higher**
1111+- **WebSocket support** (enabled by default in most environments)
1212+- **Database** (for cursor storage)
1313+1414+## Composer Installation
1515+1616+Install Signal via Composer:
1717+1818+```bash
1919+composer require socialdept/signal
2020+```
2121+2222+## Quick Setup
2323+2424+Run the installation command to set up everything automatically:
2525+2626+```bash
2727+php artisan signal:install
2828+```
2929+3030+This interactive command will:
3131+3232+1. Publish the configuration file to `config/signal.php`
3333+2. Publish database migrations for cursor storage
3434+3. Ask if you'd like to run migrations immediately
3535+4. Display next steps and helpful information
3636+3737+### What Gets Created
3838+3939+After installation, you'll have:
4040+4141+- **Configuration file**: `config/signal.php` - All Signal settings
4242+- **Migration**: `database/migrations/2024_01_01_000000_create_signal_cursors_table.php` - Cursor storage
4343+- **Signal directory**: `app/Signals/` - Where your Signals live (created when you make your first Signal)
4444+4545+## Manual Installation
4646+4747+If you prefer more control, you can install manually:
4848+4949+### 1. Publish Configuration
5050+5151+```bash
5252+php artisan vendor:publish --tag=signal-config
5353+```
5454+5555+This creates `config/signal.php` with all available options.
5656+5757+### 2. Publish Migrations
5858+5959+```bash
6060+php artisan vendor:publish --tag=signal-migrations
6161+```
6262+6363+This creates the cursor storage migration in `database/migrations/`.
6464+6565+### 3. Run Migrations
6666+6767+```bash
6868+php artisan migrate
6969+```
7070+7171+This creates the `signal_cursors` table for resuming from last position after disconnections.
7272+7373+## Environment Configuration
7474+7575+Add Signal configuration to your `.env` file:
7676+7777+```env
7878+# Consumer Mode (jetstream or firehose)
7979+SIGNAL_MODE=jetstream
8080+8181+# Jetstream URL (if using jetstream mode)
8282+SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network
8383+8484+# Firehose Host (if using firehose mode)
8585+SIGNAL_FIREHOSE_HOST=bsky.network
8686+8787+# Optional: Cursor Storage Driver (database, redis, or file)
8888+SIGNAL_CURSOR_STORAGE=database
8989+9090+# Optional: Queue Configuration
9191+SIGNAL_QUEUE_CONNECTION=redis
9292+SIGNAL_QUEUE=signal
9393+```
9494+9595+## Choosing Your Mode
9696+9797+Signal supports two modes for consuming events. Choose based on your use case:
9898+9999+### Jetstream Mode (Recommended)
100100+101101+Best for most applications:
102102+103103+```env
104104+SIGNAL_MODE=jetstream
105105+SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network
106106+```
107107+108108+**Advantages:**
109109+- Simplified JSON events (easy to work with)
110110+- Server-side collection filtering (efficient)
111111+- Lower bandwidth and processing overhead
112112+113113+### Firehose Mode
114114+115115+Best for comprehensive indexing and raw data access:
116116+117117+```env
118118+SIGNAL_MODE=firehose
119119+SIGNAL_FIREHOSE_HOST=bsky.network
120120+```
121121+122122+**Advantages:**
123123+- Access to raw CBOR/CAR data
124124+- Full AT Protocol event stream
125125+- Complete control over event processing
126126+127127+**Trade-offs:**
128128+- Client-side filtering only (higher bandwidth)
129129+- More processing overhead
130130+131131+[Learn more about choosing the right mode →](modes.md)
132132+133133+## Verify Installation
134134+135135+Check that Signal is installed correctly:
136136+137137+```bash
138138+php artisan signal:list
139139+```
140140+141141+This should display available Signals (initially none until you create them).
142142+143143+## Next Steps
144144+145145+Now that Signal is installed, you're ready to start building:
146146+147147+1. **[Create your first Signal →](quickstart.md)**
148148+2. **[Learn about Signal architecture →](signals.md)**
149149+3. **[Understand filtering options →](filtering.md)**
150150+151151+## Troubleshooting
152152+153153+### Migration Already Exists
154154+155155+If you see "migration already exists" when running `signal:install`, you've likely already installed Signal. You can safely skip this step.
156156+157157+### WebSocket Connection Issues
158158+159159+If you experience WebSocket connection issues:
160160+161161+1. Verify your firewall allows WebSocket connections
162162+2. Check that your hosting environment supports WebSockets
163163+3. Try switching Jetstream endpoints (US East vs US West)
164164+165165+### Permission Errors
166166+167167+If you encounter permission errors with cursor storage:
168168+169169+- **Database mode**: Ensure database connection is configured correctly
170170+- **Redis mode**: Verify Redis connection is available
171171+- **File mode**: Check that Laravel has write permissions to `storage/app/signal/`
172172+173173+## Uninstallation
174174+175175+To remove Signal from your application:
176176+177177+```bash
178178+# Remove the package
179179+composer remove socialdept/signal
180180+181181+# Optionally, rollback migrations
182182+php artisan migrate:rollback
183183+```
184184+185185+You can also manually delete:
186186+- `config/signal.php`
187187+- `app/Signals/` directory
188188+- Signal-related migrations
+493
docs/modes.md
···11+# Jetstream vs Firehose
22+33+Signal supports two modes for consuming AT Protocol events. Understanding the differences is crucial for building efficient, scalable applications.
44+55+## Quick Comparison
66+77+| Feature | Jetstream | Firehose |
88+|------------------|-------------------|------------------------|
99+| **Event Format** | Simplified JSON | Raw CBOR/CAR |
1010+| **Filtering** | Server-side | Client-side |
1111+| **Bandwidth** | Lower | Higher |
1212+| **Processing** | Lighter | Heavier |
1313+| **Best For** | Most applications | Comprehensive indexing |
1414+1515+## Jetstream Mode
1616+1717+Jetstream is a **simplified, JSON-based event stream** built on top of the AT Protocol Firehose.
1818+1919+### When to Use Jetstream
2020+2121+Choose Jetstream if you're:
2222+2323+- Building production applications where efficiency matters
2424+- Concerned about bandwidth and server costs
2525+- Processing high volumes of events
2626+- Want server-side filtering for reduced bandwidth
2727+2828+### Configuration
2929+3030+Set Jetstream as your mode in `.env`:
3131+3232+```env
3333+SIGNAL_MODE=jetstream
3434+SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network
3535+```
3636+3737+### Available Endpoints
3838+3939+Jetstream has multiple regional endpoints:
4040+4141+**US East (Default):**
4242+```env
4343+SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network
4444+```
4545+4646+**US West:**
4747+```env
4848+SIGNAL_JETSTREAM_URL=wss://jetstream1.us-west.bsky.network
4949+```
5050+5151+Choose the endpoint closest to your server for best performance.
5252+5353+### Advantages
5454+5555+**1. Simplified JSON Format**
5656+5757+Events arrive as clean JSON objects:
5858+5959+```json
6060+{
6161+ "did": "did:plc:z72i7hdynmk6r22z27h6tvur",
6262+ "time_us": 1234567890,
6363+ "kind": "commit",
6464+ "commit": {
6565+ "rev": "abc123",
6666+ "operation": "create",
6767+ "collection": "app.bsky.feed.post",
6868+ "rkey": "3k2yihcrr2c2a",
6969+ "record": {
7070+ "text": "Hello World!",
7171+ "createdAt": "2024-01-15T10:30:00Z"
7272+ }
7373+ }
7474+}
7575+```
7676+7777+No complex parsing or decoding required.
7878+7979+**2. Server-Side Filtering**
8080+8181+Your collection filters are sent to Jetstream:
8282+8383+```php
8484+public function collections(): ?array
8585+{
8686+ return ['app.bsky.feed.post', 'app.bsky.feed.like'];
8787+}
8888+```
8989+9090+Jetstream only sends matching events, dramatically reducing bandwidth.
9191+9292+**3. Lower Bandwidth**
9393+9494+Only receive the events you care about:
9595+9696+- **Jetstream**: Receive ~1,000 events/sec for specific collections
9797+- **Firehose**: Receive ~50,000 events/sec for everything
9898+9999+**4. Lower Processing Overhead**
100100+101101+JSON parsing is faster than CBOR/CAR decoding:
102102+103103+- **Jetstream**: Simple JSON deserialization
104104+- **Firehose**: Complex CBOR/CAR decoding with `revolution/laravel-bluesky`
105105+106106+### Limitations
107107+108108+**1. Client-Side Wildcards**
109109+110110+Wildcard patterns work client-side only:
111111+112112+```php
113113+public function collections(): ?array
114114+{
115115+ return ['app.bsky.feed.*']; // Still receives all collections
116116+}
117117+```
118118+119119+The wildcard matching happens in your app, not on the server.
120120+121121+### Example Configuration
122122+123123+```php
124124+// config/signal.php
125125+return [
126126+ 'mode' => env('SIGNAL_MODE', 'jetstream'),
127127+128128+ 'jetstream' => [
129129+ 'websocket_url' => env(
130130+ 'SIGNAL_JETSTREAM_URL',
131131+ 'wss://jetstream2.us-east.bsky.network'
132132+ ),
133133+ ],
134134+];
135135+```
136136+137137+## Firehose Mode
138138+139139+Firehose is the **raw AT Protocol event stream** with comprehensive support for all collections.
140140+141141+### When to Use Firehose
142142+143143+Choose Firehose if you're:
144144+145145+- Building comprehensive indexing systems
146146+- Developing AT Protocol infrastructure
147147+- Need access to raw CBOR/CAR data
148148+- Prefer client-side filtering control
149149+150150+### Configuration
151151+152152+Set Firehose as your mode in `.env`:
153153+154154+```env
155155+SIGNAL_MODE=firehose
156156+SIGNAL_FIREHOSE_HOST=bsky.network
157157+```
158158+159159+### Advantages
160160+161161+**1. Raw Event Access**
162162+163163+Full access to raw AT Protocol data:
164164+165165+```php
166166+public function handle(SignalEvent $event): void
167167+{
168168+ // Access raw CBOR/CAR data
169169+ $cid = $event->commit->cid;
170170+ $blocks = $event->commit->blocks;
171171+}
172172+```
173173+174174+**2. Comprehensive Events**
175175+176176+Every event from the AT Protocol network arrives:
177177+178178+- All collections (standard and custom)
179179+- All operations (create, update, delete)
180180+- All metadata and context
181181+- Complete repository commits
182182+183183+**3. Complete Control**
184184+185185+Full access to raw AT Protocol data:
186186+187187+- CID (Content Identifiers)
188188+- Block structures
189189+- CAR file data
190190+- Complete repository commits
191191+192192+### Trade-offs
193193+194194+**1. Client-Side Filtering**
195195+196196+All filtering happens in your application:
197197+198198+```php
199199+public function collections(): ?array
200200+{
201201+ return ['app.bsky.feed.post']; // Still receives all events
202202+}
203203+```
204204+205205+Your app receives everything and filters locally.
206206+207207+**2. Higher Bandwidth**
208208+209209+Receive the full event stream:
210210+211211+- **~50,000+ events per second** during peak times
212212+- **~10-50 MB/s** of data throughput
213213+- Requires adequate network capacity
214214+215215+**3. More Processing Overhead**
216216+217217+Complex CBOR/CAR decoding:
218218+219219+```php
220220+// Signal automatically handles decoding using revolution/laravel-bluesky
221221+$record = $event->getRecord(); // Decoded from CBOR/CAR
222222+```
223223+224224+Processing is more CPU-intensive than Jetstream's JSON.
225225+226226+**4. Requires revolution/laravel-bluesky**
227227+228228+Firehose mode depends on the `revolution/laravel-bluesky` package for decoding:
229229+230230+```bash
231231+composer require revolution/bluesky
232232+```
233233+234234+Signal handles this dependency automatically.
235235+236236+### Example Configuration
237237+238238+```php
239239+// config/signal.php
240240+return [
241241+ 'mode' => env('SIGNAL_MODE', 'jetstream'),
242242+243243+ 'firehose' => [
244244+ 'host' => env('SIGNAL_FIREHOSE_HOST', 'bsky.network'),
245245+ ],
246246+];
247247+```
248248+249249+The WebSocket URL is constructed as:
250250+```
251251+wss://{host}/xrpc/com.atproto.sync.subscribeRepos
252252+```
253253+254254+## Choosing the Right Mode
255255+256256+### Decision Tree
257257+258258+```
259259+Do you need raw CBOR/CAR access?
260260+├─ Yes → Use Firehose
261261+└─ No
262262+ │
263263+ Do you want server-side filtering?
264264+ ├─ Yes → Use Jetstream (recommended)
265265+ └─ No → Use Firehose
266266+```
267267+268268+### Use Case Examples
269269+270270+**Social Media Analytics (Jetstream)**
271271+272272+```php
273273+// Efficient monitoring with server-side filtering
274274+public function collections(): ?array
275275+{
276276+ return [
277277+ 'app.bsky.feed.post',
278278+ 'app.bsky.feed.like',
279279+ 'app.bsky.graph.follow',
280280+ ];
281281+}
282282+```
283283+284284+**Content Moderation (Jetstream)**
285285+286286+```php
287287+// Standard content monitoring
288288+public function collections(): ?array
289289+{
290290+ return ['app.bsky.feed.*'];
291291+}
292292+```
293293+294294+**Comprehensive Indexer (Firehose)**
295295+296296+```php
297297+// Index everything with raw data access
298298+public function collections(): ?array
299299+{
300300+ return null; // All collections
301301+}
302302+```
303303+304304+## Switching Between Modes
305305+306306+You can switch modes without code changes:
307307+308308+### Option 1: Environment Variable
309309+310310+```env
311311+# Development - comprehensive testing
312312+SIGNAL_MODE=firehose
313313+314314+# Production - efficient processing
315315+SIGNAL_MODE=jetstream
316316+```
317317+318318+### Option 2: Runtime Configuration
319319+320320+```php
321321+use SocialDept\Signal\Facades\Signal;
322322+323323+// Set mode dynamically
324324+config(['signal.mode' => 'jetstream']);
325325+326326+Signal::start();
327327+```
328328+329329+## Performance Comparison
330330+331331+### Bandwidth Usage
332332+333333+**Processing 1 hour of posts:**
334334+335335+| Mode | Data Received | Bandwidth |
336336+|-----------|-------------------|-----------|
337337+| Jetstream | ~50,000 events | ~10 MB |
338338+| Firehose | ~5,000,000 events | ~500 MB |
339339+340340+**Savings:** 50x reduction with Jetstream
341341+342342+### CPU Usage
343343+344344+**Processing same events:**
345345+346346+| Mode | CPU Usage | Processing Time |
347347+|-----------|-----------|-----------------|
348348+| Jetstream | ~5% | 0.1ms per event |
349349+| Firehose | ~20% | 0.4ms per event |
350350+351351+**Savings:** 4x more efficient with Jetstream
352352+353353+### Cost Implications
354354+355355+For a medium-traffic application:
356356+357357+| Mode | Monthly Bandwidth | Est. Cost* |
358358+|-----------|-------------------|------------|
359359+| Jetstream | ~20 GB | ~$2 |
360360+| Firehose | ~10 TB | ~$1000 |
361361+362362+*Estimates vary by provider and usage
363363+364364+## Best Practices
365365+366366+### Start with Jetstream
367367+368368+Start with Jetstream for most applications:
369369+370370+```env
371371+SIGNAL_MODE=jetstream
372372+```
373373+374374+Switch to Firehose only if you need raw CBOR/CAR access.
375375+376376+### Use Firehose for Development
377377+378378+Test with Firehose in development to see all events:
379379+380380+```env
381381+# .env.local
382382+SIGNAL_MODE=firehose
383383+384384+# .env.production
385385+SIGNAL_MODE=jetstream
386386+```
387387+388388+### Monitor Performance
389389+390390+Track your Signal's performance:
391391+392392+```php
393393+public function handle(SignalEvent $event): void
394394+{
395395+ $start = microtime(true);
396396+397397+ // Your logic
398398+399399+ $duration = microtime(true) - $start;
400400+401401+ if ($duration > 0.1) {
402402+ Log::warning('Slow signal processing', [
403403+ 'signal' => static::class,
404404+ 'duration' => $duration,
405405+ 'mode' => config('signal.mode'),
406406+ ]);
407407+ }
408408+}
409409+```
410410+411411+### Use Queues with Firehose
412412+413413+Firehose generates high volume. Use queues to avoid blocking:
414414+415415+```php
416416+public function shouldQueue(): bool
417417+{
418418+ // Queue when using Firehose
419419+ return config('signal.mode') === 'firehose';
420420+}
421421+```
422422+423423+[Learn more about queue integration →](queues.md)
424424+425425+## Testing Both Modes
426426+427427+Test your Signals work in both modes:
428428+429429+```bash
430430+# Test with Jetstream
431431+SIGNAL_MODE=jetstream php artisan signal:test MySignal
432432+433433+# Test with Firehose
434434+SIGNAL_MODE=firehose php artisan signal:test MySignal
435435+```
436436+437437+[Learn more about testing →](testing.md)
438438+439439+## Common Questions
440440+441441+### Can I use both modes simultaneously?
442442+443443+No, each consumer runs in one mode. However, you can run multiple consumers:
444444+445445+```bash
446446+# Terminal 1 - Jetstream consumer
447447+SIGNAL_MODE=jetstream php artisan signal:consume
448448+449449+# Terminal 2 - Firehose consumer
450450+SIGNAL_MODE=firehose php artisan signal:consume
451451+```
452452+453453+### Will my Signals break if I switch modes?
454454+455455+Signals work in both modes without changes. The main difference is:
456456+- Jetstream provides server-side filtering (more efficient)
457457+- Firehose provides raw CBOR/CAR data access (more comprehensive)
458458+459459+### How do I know which mode I'm using?
460460+461461+Check at runtime:
462462+463463+```php
464464+$mode = config('signal.mode'); // 'jetstream' or 'firehose'
465465+```
466466+467467+Or via Facade:
468468+469469+```php
470470+use SocialDept\Signal\Facades\Signal;
471471+472472+$mode = Signal::getMode();
473473+```
474474+475475+### Can I switch modes while consuming?
476476+477477+No, you must restart the consumer:
478478+479479+```bash
480480+# Stop current consumer (Ctrl+C)
481481+482482+# Change mode
483483+# Edit .env: SIGNAL_MODE=firehose
484484+485485+# Start new consumer
486486+php artisan signal:consume
487487+```
488488+489489+## Next Steps
490490+491491+- **[Learn about queue integration →](queues.md)** - Handle high-volume events efficiently
492492+- **[Review configuration options →](configuration.md)** - Fine-tune your setup
493493+- **[See real-world examples →](examples.md)** - Learn from production patterns
+672
docs/queues.md
···11+# Queue Integration
22+33+Processing AT Protocol events can be resource-intensive. Signal's queue integration lets you handle events asynchronously, preventing bottlenecks and improving performance.
44+55+## Why Use Queues?
66+77+### Without Queues (Synchronous)
88+99+```php
1010+public function handle(SignalEvent $event): void
1111+{
1212+ $this->performExpensiveAnalysis($event); // Blocks for 2 seconds
1313+ $this->sendNotifications($event); // Blocks for 1 second
1414+ $this->updateDatabase($event); // Blocks for 0.5 seconds
1515+}
1616+```
1717+1818+**Problems:**
1919+- Consumer blocks while processing (3.5 seconds per event)
2020+- Events queue up during slow operations
2121+- Risk of disconnection during long processing
2222+- Can't scale horizontally
2323+- Memory issues with long-running processes
2424+2525+### With Queues (Asynchronous)
2626+2727+```php
2828+public function shouldQueue(): bool
2929+{
3030+ return true;
3131+}
3232+3333+public function handle(SignalEvent $event): void
3434+{
3535+ $this->performExpensiveAnalysis($event); // Runs in background
3636+ $this->sendNotifications($event); // Runs in background
3737+ $this->updateDatabase($event); // Runs in background
3838+}
3939+```
4040+4141+**Benefits:**
4242+- Consumer stays responsive
4343+- Processing happens in parallel
4444+- Scale by adding queue workers
4545+- Better memory management
4646+- Automatic retry on failures
4747+4848+## Basic Queue Configuration
4949+5050+### Enable Queueing
5151+5252+Simply return `true` from `shouldQueue()`:
5353+5454+```php
5555+class MySignal extends Signal
5656+{
5757+ public function eventTypes(): array
5858+ {
5959+ return ['commit'];
6060+ }
6161+6262+ public function shouldQueue(): bool
6363+ {
6464+ return true; // Enable queuing
6565+ }
6666+6767+ public function handle(SignalEvent $event): void
6868+ {
6969+ // This now runs in a queue job
7070+ }
7171+}
7272+```
7373+7474+That's it! Signal automatically:
7575+- Creates a queue job for each event
7676+- Serializes the event data
7777+- Dispatches to Laravel's queue system
7878+- Handles retries and failures
7979+8080+### Default Queue Configuration
8181+8282+Signal uses your Laravel queue configuration:
8383+8484+```env
8585+# Default queue connection
8686+QUEUE_CONNECTION=redis
8787+8888+# Signal-specific queue (optional)
8989+SIGNAL_QUEUE=signal
9090+9191+# Signal queue connection (optional)
9292+SIGNAL_QUEUE_CONNECTION=redis
9393+```
9494+9595+## Customizing Queue Behavior
9696+9797+### Specify Queue Name
9898+9999+Send events to a specific queue:
100100+101101+```php
102102+public function shouldQueue(): bool
103103+{
104104+ return true;
105105+}
106106+107107+public function queue(): string
108108+{
109109+ return 'high-priority'; // Queue name
110110+}
111111+```
112112+113113+Now your events go to the `high-priority` queue:
114114+115115+```bash
116116+php artisan queue:work --queue=high-priority
117117+```
118118+119119+### Specify Queue Connection
120120+121121+Use a different queue connection:
122122+123123+```php
124124+public function shouldQueue(): bool
125125+{
126126+ return true;
127127+}
128128+129129+public function queueConnection(): string
130130+{
131131+ return 'redis'; // Connection name
132132+}
133133+```
134134+135135+### Combine Queue Configuration
136136+137137+```php
138138+public function shouldQueue(): bool
139139+{
140140+ return true;
141141+}
142142+143143+public function queueConnection(): string
144144+{
145145+ return 'redis';
146146+}
147147+148148+public function queue(): string
149149+{
150150+ return 'signal-events';
151151+}
152152+```
153153+154154+## Running Queue Workers
155155+156156+### Start a Worker
157157+158158+Process queued events:
159159+160160+```bash
161161+php artisan queue:work
162162+```
163163+164164+### Process Specific Queue
165165+166166+```bash
167167+php artisan queue:work --queue=signal
168168+```
169169+170170+### Multiple Queues with Priority
171171+172172+Process high-priority queue first:
173173+174174+```bash
175175+php artisan queue:work --queue=high-priority,default
176176+```
177177+178178+### Scale with Multiple Workers
179179+180180+Run multiple workers for throughput:
181181+182182+```bash
183183+# Terminal 1
184184+php artisan queue:work --queue=signal
185185+186186+# Terminal 2
187187+php artisan queue:work --queue=signal
188188+189189+# Terminal 3
190190+php artisan queue:work --queue=signal
191191+```
192192+193193+### Supervisor Configuration
194194+195195+For production, use Supervisor to manage workers:
196196+197197+```ini
198198+[program:signal-queue-worker]
199199+process_name=%(program_name)s_%(process_num)02d
200200+command=php /path/to/artisan queue:work --sleep=3 --tries=3 --queue=signal
201201+autostart=true
202202+autorestart=true
203203+stopasgroup=true
204204+killasgroup=true
205205+user=www-data
206206+numprocs=4
207207+redirect_stderr=true
208208+stdout_logfile=/path/to/logs/signal-worker.log
209209+stopwaitsecs=3600
210210+```
211211+212212+This creates 4 workers processing the `signal` queue.
213213+214214+## Error Handling
215215+216216+### Failed Method
217217+218218+Handle job failures:
219219+220220+```php
221221+public function shouldQueue(): bool
222222+{
223223+ return true;
224224+}
225225+226226+public function handle(SignalEvent $event): void
227227+{
228228+ // Your logic that might fail
229229+ $this->riskyOperation($event);
230230+}
231231+232232+public function failed(SignalEvent $event, \Throwable $exception): void
233233+{
234234+ Log::error('Signal processing failed', [
235235+ 'signal' => static::class,
236236+ 'did' => $event->did,
237237+ 'collection' => $event->getCollection(),
238238+ 'error' => $exception->getMessage(),
239239+ 'trace' => $exception->getTraceAsString(),
240240+ ]);
241241+242242+ // Optional: Send alerts
243243+ $this->notifyAdmin($exception);
244244+245245+ // Optional: Store for manual review
246246+ FailedSignal::create([
247247+ 'event_data' => $event->toArray(),
248248+ 'exception' => $exception->getMessage(),
249249+ ]);
250250+}
251251+```
252252+253253+### Automatic Retries
254254+255255+Laravel automatically retries failed jobs:
256256+257257+```bash
258258+# Retry up to 3 times
259259+php artisan queue:work --tries=3
260260+```
261261+262262+Configure retry delay:
263263+264264+```php
265265+public function retryAfter(): int
266266+{
267267+ return 60; // Wait 60 seconds before retry
268268+}
269269+```
270270+271271+### Exponential Backoff
272272+273273+Increase delay between retries:
274274+275275+```php
276276+public function backoff(): array
277277+{
278278+ return [10, 30, 60]; // 10s, then 30s, then 60s
279279+}
280280+```
281281+282282+## Performance Optimization
283283+284284+### Batch Processing
285285+286286+Process multiple events at once:
287287+288288+```php
289289+use Illuminate\Support\Collection;
290290+291291+class BatchPostSignal extends Signal
292292+{
293293+ public function shouldQueue(): bool
294294+{
295295+ return true;
296296+ }
297297+298298+ public function handle(SignalEvent $event): void
299299+ {
300300+ // Collect events in cache
301301+ $events = Cache::get('pending_posts', []);
302302+ $events[] = $event->toArray();
303303+304304+ Cache::put('pending_posts', $events, now()->addMinutes(5));
305305+306306+ // Process in batches of 100
307307+ if (count($events) >= 100) {
308308+ $this->processBatch($events);
309309+ Cache::forget('pending_posts');
310310+ }
311311+ }
312312+313313+ private function processBatch(array $events): void
314314+ {
315315+ // Bulk insert, API calls, etc.
316316+ }
317317+}
318318+```
319319+320320+### Conditional Queuing
321321+322322+Queue only expensive operations:
323323+324324+```php
325325+public function shouldQueue(): bool
326326+{
327327+ // Queue during high traffic
328328+ return now()->hour >= 9 && now()->hour <= 17;
329329+}
330330+```
331331+332332+Or based on event type:
333333+334334+```php
335335+public function handle(SignalEvent $event): void
336336+{
337337+ if ($this->isExpensive($event)) {
338338+ dispatch(function () use ($event) {
339339+ $this->handleExpensive($event);
340340+ })->onQueue('slow-operations');
341341+ } else {
342342+ $this->handleQuick($event);
343343+ }
344344+}
345345+```
346346+347347+### Rate Limiting
348348+349349+Prevent overwhelming external APIs:
350350+351351+```php
352352+use Illuminate\Support\Facades\RateLimiter;
353353+354354+public function handle(SignalEvent $event): void
355355+{
356356+ RateLimiter::attempt(
357357+ 'api-calls',
358358+ $perMinute = 100,
359359+ function () use ($event) {
360360+ $this->callExternalAPI($event);
361361+ }
362362+ );
363363+}
364364+```
365365+366366+## Common Patterns
367367+368368+### High-Volume Signal
369369+370370+Process millions of events efficiently:
371371+372372+```php
373373+class HighVolumeSignal extends Signal
374374+{
375375+ public function eventTypes(): array
376376+ {
377377+ return ['commit'];
378378+ }
379379+380380+ public function collections(): ?array
381381+ {
382382+ return ['app.bsky.feed.post'];
383383+ }
384384+385385+ public function shouldQueue(): bool
386386+ {
387387+ return true;
388388+ }
389389+390390+ public function queue(): string
391391+ {
392392+ return 'high-volume';
393393+ }
394394+395395+ public function handle(SignalEvent $event): void
396396+ {
397397+ // Lightweight processing only
398398+ $this->incrementCounter($event);
399399+ }
400400+}
401401+```
402402+403403+Run many workers:
404404+405405+```bash
406406+# 10 workers on high-volume queue
407407+php artisan queue:work --queue=high-volume --workers=10
408408+```
409409+410410+### Priority Queues
411411+412412+Different priorities for different events:
413413+414414+```php
415415+class PrioritySignal extends Signal
416416+{
417417+ public function shouldQueue(): bool
418418+ {
419419+ return true;
420420+ }
421421+422422+ public function queue(): string
423423+ {
424424+ // Determine priority based on event
425425+ return $this->getQueueForEvent();
426426+ }
427427+428428+ private function getQueueForEvent(): string
429429+ {
430430+ // Check event attributes
431431+ // Return 'high', 'medium', or 'low'
432432+ }
433433+}
434434+```
435435+436436+Process high-priority first:
437437+438438+```bash
439439+php artisan queue:work --queue=high,medium,low
440440+```
441441+442442+### Delayed Processing
443443+444444+Delay event processing:
445445+446446+```php
447447+public function handle(SignalEvent $event): void
448448+{
449449+ // Dispatch with delay
450450+ dispatch(function () use ($event) {
451451+ $this->processLater($event);
452452+ })->delay(now()->addMinutes(5));
453453+}
454454+```
455455+456456+### Scheduled Batch Processing
457457+458458+Collect events and process on schedule:
459459+460460+```php
461461+// Signal collects events
462462+class CollectorSignal extends Signal
463463+{
464464+ public function handle(SignalEvent $event): void
465465+ {
466466+ PendingEvent::create([
467467+ 'data' => $event->toArray(),
468468+ ]);
469469+ }
470470+}
471471+472472+// Scheduled command processes batch
473473+// app/Console/Kernel.php
474474+protected function schedule(Schedule $schedule)
475475+{
476476+ $schedule->call(function () {
477477+ $events = PendingEvent::all();
478478+ $this->processBatch($events);
479479+ PendingEvent::truncate();
480480+ })->hourly();
481481+}
482482+```
483483+484484+## Monitoring Queues
485485+486486+### Check Queue Status
487487+488488+```bash
489489+# View failed jobs
490490+php artisan queue:failed
491491+492492+# Retry failed job
493493+php artisan queue:retry {id}
494494+495495+# Retry all failed
496496+php artisan queue:retry all
497497+498498+# Clear failed jobs
499499+php artisan queue:flush
500500+```
501501+502502+### Queue Metrics
503503+504504+Track queue performance:
505505+506506+```php
507507+use Illuminate\Support\Facades\Queue;
508508+509509+Queue::after(function ($connection, $job, $data) {
510510+ Log::info('Job processed', [
511511+ 'queue' => $job->queue,
512512+ 'class' => $job->resolveName(),
513513+ 'attempts' => $job->attempts(),
514514+ ]);
515515+});
516516+```
517517+518518+### Horizon (Recommended)
519519+520520+Use Laravel Horizon for Redis queues:
521521+522522+```bash
523523+composer require laravel/horizon
524524+php artisan horizon:install
525525+php artisan horizon
526526+```
527527+528528+View dashboard at `/horizon`.
529529+530530+## Testing Queued Signals
531531+532532+### Test with Fake Queue
533533+534534+```php
535535+use Illuminate\Support\Facades\Queue;
536536+537537+/** @test */
538538+public function it_queues_events()
539539+{
540540+ Queue::fake();
541541+542542+ $signal = new MySignal();
543543+ $event = $this->createSampleEvent();
544544+545545+ // Assert queue behavior
546546+ $this->assertTrue($signal->shouldQueue());
547547+548548+ // Process would normally queue
549549+ $signal->handle($event);
550550+551551+ // Verify job was queued
552552+ Queue::assertPushed(SignalJob::class);
553553+}
554554+```
555555+556556+### Test Synchronously
557557+558558+Disable queueing for tests:
559559+560560+```php
561561+/** @test */
562562+public function it_processes_events()
563563+{
564564+ config(['queue.default' => 'sync']);
565565+566566+ $signal = new MySignal();
567567+ $event = $this->createSampleEvent();
568568+569569+ $signal->handle($event);
570570+571571+ // Assert processing happened
572572+ $this->assertDatabaseHas('posts', [...]);
573573+}
574574+```
575575+576576+[Learn more about testing →](testing.md)
577577+578578+## Production Checklist
579579+580580+### Infrastructure
581581+582582+- [ ] Queue driver configured (Redis recommended)
583583+- [ ] Supervisor installed and configured
584584+- [ ] Multiple workers running
585585+- [ ] Worker auto-restart enabled
586586+- [ ] Logs configured and monitored
587587+588588+### Configuration
589589+590590+- [ ] Queue connection set correctly
591591+- [ ] Queue names configured
592592+- [ ] Retry attempts configured
593593+- [ ] Timeout values appropriate
594594+- [ ] Memory limits set
595595+596596+### Monitoring
597597+598598+- [ ] Queue length monitored
599599+- [ ] Failed jobs tracked
600600+- [ ] Worker health checked
601601+- [ ] Processing times measured
602602+- [ ] Horizon installed (if using Redis)
603603+604604+### Scaling
605605+606606+- [ ] Worker count appropriate for volume
607607+- [ ] Priority queues configured
608608+- [ ] Rate limiting implemented
609609+- [ ] Database connection pooling enabled
610610+- [ ] Redis maxmemory policy set
611611+612612+## Common Issues
613613+614614+### Queue Jobs Not Processing
615615+616616+**Check worker is running:**
617617+```bash
618618+php artisan queue:work
619619+```
620620+621621+**Check queue connection:**
622622+```php
623623+// Should match QUEUE_CONNECTION
624624+config('queue.default')
625625+```
626626+627627+### Jobs Timing Out
628628+629629+**Increase timeout:**
630630+```bash
631631+php artisan queue:work --timeout=300
632632+```
633633+634634+**Or in Signal:**
635635+```php
636636+public function timeout(): int
637637+{
638638+ return 300; // 5 minutes
639639+}
640640+```
641641+642642+### Memory Leaks
643643+644644+**Restart workers periodically:**
645645+```bash
646646+php artisan queue:work --max-jobs=1000
647647+```
648648+649649+Or:
650650+```bash
651651+php artisan queue:work --max-time=3600
652652+```
653653+654654+### Failed Jobs Piling Up
655655+656656+**Review failures:**
657657+```bash
658658+php artisan queue:failed
659659+```
660660+661661+**Retry or delete:**
662662+```bash
663663+php artisan queue:retry all
664664+# or
665665+php artisan queue:flush
666666+```
667667+668668+## Next Steps
669669+670670+- **[Review configuration options →](configuration.md)** - Fine-tune queue settings
671671+- **[Learn about testing →](testing.md)** - Test queued Signals
672672+- **[See real-world examples →](examples.md)** - Learn from production queue patterns
+391
docs/quickstart.md
···11+# Quickstart Guide
22+33+This guide will walk you through building your first Signal and consuming AT Protocol events in under 5 minutes.
44+55+## Prerequisites
66+77+Before starting, ensure you have:
88+99+- [Installed Signal](installation.md) in your Laravel application
1010+- Run `php artisan signal:install` successfully
1111+- Basic familiarity with Laravel
1212+1313+## Your First Signal
1414+1515+We'll build a Signal that logs every new post created on Bluesky.
1616+1717+### Step 1: Generate a Signal
1818+1919+Use the Artisan command to create a new Signal:
2020+2121+```bash
2222+php artisan make:signal NewPostSignal
2323+```
2424+2525+This creates `app/Signals/NewPostSignal.php` with a basic template.
2626+2727+### Step 2: Define the Signal
2828+2929+Open the generated file and update it:
3030+3131+```php
3232+<?php
3333+3434+namespace App\Signals;
3535+3636+use SocialDept\Signal\Events\SignalEvent;
3737+use SocialDept\Signal\Signals\Signal;
3838+use Illuminate\Support\Facades\Log;
3939+4040+class NewPostSignal extends Signal
4141+{
4242+ /**
4343+ * Define which event types to listen for.
4444+ */
4545+ public function eventTypes(): array
4646+ {
4747+ return ['commit']; // Listen for repository commits
4848+ }
4949+5050+ /**
5151+ * Filter by specific collections.
5252+ */
5353+ public function collections(): ?array
5454+ {
5555+ return ['app.bsky.feed.post']; // Only handle posts
5656+ }
5757+5858+ /**
5959+ * Handle the event when it arrives.
6060+ */
6161+ public function handle(SignalEvent $event): void
6262+ {
6363+ $record = $event->getRecord();
6464+6565+ Log::info('New post created', [
6666+ 'author' => $event->did,
6767+ 'text' => $record->text ?? null,
6868+ 'created_at' => $record->createdAt ?? null,
6969+ ]);
7070+ }
7171+}
7272+```
7373+7474+### Step 3: Start Consuming Events
7575+7676+Run the consumer to start listening:
7777+7878+```bash
7979+php artisan signal:consume
8080+```
8181+8282+You should see output like:
8383+8484+```
8585+Starting Signal consumer in jetstream mode...
8686+Connecting to wss://jetstream2.us-east.bsky.network...
8787+Connected! Listening for events...
8888+```
8989+9090+**Congratulations!** Your Signal is now processing every new post on Bluesky in real-time. Check your Laravel logs to see the posts coming in.
9191+9292+## Understanding What Just Happened
9393+9494+Let's break down the Signal you created:
9595+9696+### Event Types
9797+9898+```php
9999+public function eventTypes(): array
100100+{
101101+ return ['commit'];
102102+}
103103+```
104104+105105+This tells Signal you want **commit** events, which represent changes to repositories (like creating posts, likes, follows, etc.).
106106+107107+Available event types:
108108+- `commit` - Repository commits (most common)
109109+- `identity` - Identity changes (handle updates)
110110+- `account` - Account status changes
111111+112112+### Collections
113113+114114+```php
115115+public function collections(): ?array
116116+{
117117+ return ['app.bsky.feed.post'];
118118+}
119119+```
120120+121121+This filters to only **post** collections. Without this filter, your Signal would receive all commit events for every collection type.
122122+123123+Common collections:
124124+- `app.bsky.feed.post` - Posts
125125+- `app.bsky.feed.like` - Likes
126126+- `app.bsky.graph.follow` - Follows
127127+- `app.bsky.feed.repost` - Reposts
128128+129129+[Learn more about filtering →](filtering.md)
130130+131131+### Handler Method
132132+133133+```php
134134+public function handle(SignalEvent $event): void
135135+{
136136+ $record = $event->getRecord();
137137+ // Your logic here
138138+}
139139+```
140140+141141+This is where your code runs for each matching event. The `$event` object contains:
142142+143143+- `did` - The user's DID (decentralized identifier)
144144+- `timeUs` - Timestamp in microseconds
145145+- `commit` - Commit details (collection, operation, record key)
146146+- `getRecord()` - The actual record data
147147+148148+## Next Steps
149149+150150+Now that you've built your first Signal, let's make it more useful.
151151+152152+### Add More Filtering
153153+154154+Track specific operations only:
155155+156156+```php
157157+use SocialDept\Signal\Enums\SignalCommitOperation;
158158+159159+public function operations(): ?array
160160+{
161161+ return [SignalCommitOperation::Create]; // Only new posts, not edits
162162+}
163163+```
164164+165165+[Learn more about filtering →](filtering.md)
166166+167167+### Process Events Asynchronously
168168+169169+For expensive operations, use Laravel queues:
170170+171171+```php
172172+public function shouldQueue(): bool
173173+{
174174+ return true;
175175+}
176176+177177+public function handle(SignalEvent $event): void
178178+{
179179+ // This now runs in a background job
180180+ $this->performExpensiveAnalysis($event);
181181+}
182182+```
183183+184184+[Learn more about queues →](queues.md)
185185+186186+### Store Data
187187+188188+Let's store posts in your database:
189189+190190+```php
191191+use App\Models\Post;
192192+193193+public function handle(SignalEvent $event): void
194194+{
195195+ $record = $event->getRecord();
196196+197197+ Post::updateOrCreate(
198198+ [
199199+ 'did' => $event->did,
200200+ 'rkey' => $event->commit->rkey,
201201+ ],
202202+ [
203203+ 'text' => $record->text ?? null,
204204+ 'created_at' => $record->createdAt,
205205+ ]
206206+ );
207207+}
208208+```
209209+210210+### Handle Multiple Collections
211211+212212+Use wildcards to match multiple collections:
213213+214214+```php
215215+public function collections(): ?array
216216+{
217217+ return [
218218+ 'app.bsky.feed.*', // All feed events
219219+ ];
220220+}
221221+222222+public function handle(SignalEvent $event): void
223223+{
224224+ $collection = $event->getCollection();
225225+226226+ match ($collection) {
227227+ 'app.bsky.feed.post' => $this->handlePost($event),
228228+ 'app.bsky.feed.like' => $this->handleLike($event),
229229+ 'app.bsky.feed.repost' => $this->handleRepost($event),
230230+ default => null,
231231+ };
232232+}
233233+```
234234+235235+## Building Something Real
236236+237237+Let's build a simple engagement tracker:
238238+239239+```php
240240+<?php
241241+242242+namespace App\Signals;
243243+244244+use App\Models\EngagementMetric;
245245+use SocialDept\Signal\Enums\SignalCommitOperation;
246246+use SocialDept\Signal\Events\SignalEvent;
247247+use SocialDept\Signal\Signals\Signal;
248248+249249+class EngagementTrackerSignal extends Signal
250250+{
251251+ public function eventTypes(): array
252252+ {
253253+ return ['commit'];
254254+ }
255255+256256+ public function collections(): ?array
257257+ {
258258+ return [
259259+ 'app.bsky.feed.post',
260260+ 'app.bsky.feed.like',
261261+ 'app.bsky.feed.repost',
262262+ ];
263263+ }
264264+265265+ public function operations(): ?array
266266+ {
267267+ return [SignalCommitOperation::Create];
268268+ }
269269+270270+ public function shouldQueue(): bool
271271+ {
272272+ return true; // Process in background
273273+ }
274274+275275+ public function handle(SignalEvent $event): void
276276+ {
277277+ EngagementMetric::create([
278278+ 'date' => now()->toDateString(),
279279+ 'collection' => $event->getCollection(),
280280+ 'event_type' => 'create',
281281+ 'count' => 1,
282282+ ]);
283283+ }
284284+}
285285+```
286286+287287+This Signal tracks all engagement activity (posts, likes, reposts) and stores metrics for analysis.
288288+289289+## Testing Your Signal
290290+291291+Before running in production, test your Signal with sample data:
292292+293293+```bash
294294+php artisan signal:test NewPostSignal
295295+```
296296+297297+This will run your Signal with a sample event and show you the output.
298298+299299+[Learn more about testing →](testing.md)
300300+301301+## Common Patterns
302302+303303+### Only Process Specific Users
304304+305305+```php
306306+public function dids(): ?array
307307+{
308308+ return [
309309+ 'did:plc:z72i7hdynmk6r22z27h6tvur', // Specific user
310310+ ];
311311+}
312312+```
313313+314314+### Add Custom Filtering Logic
315315+316316+```php
317317+public function shouldHandle(SignalEvent $event): bool
318318+{
319319+ $record = $event->getRecord();
320320+321321+ // Only handle posts with images
322322+ return isset($record->embed);
323323+}
324324+```
325325+326326+### Handle Failures Gracefully
327327+328328+```php
329329+public function failed(SignalEvent $event, \Throwable $exception): void
330330+{
331331+ Log::error('Signal processing failed', [
332332+ 'event' => $event->toArray(),
333333+ 'error' => $exception->getMessage(),
334334+ ]);
335335+336336+ // Optionally notify admins, store for retry, etc.
337337+}
338338+```
339339+340340+## Running in Production
341341+342342+### Using Supervisor
343343+344344+For production, run Signal under a process monitor like Supervisor:
345345+346346+```ini
347347+[program:signal-consumer]
348348+process_name=%(program_name)s
349349+command=php /path/to/artisan signal:consume
350350+autostart=true
351351+autorestart=true
352352+user=www-data
353353+redirect_stderr=true
354354+stdout_logfile=/path/to/logs/signal-consumer.log
355355+```
356356+357357+### Starting from Last Position
358358+359359+Signal automatically saves cursor positions, so it resumes from where it left off:
360360+361361+```bash
362362+php artisan signal:consume
363363+```
364364+365365+To start fresh and ignore stored position:
366366+367367+```bash
368368+php artisan signal:consume --fresh
369369+```
370370+371371+To start from a specific cursor:
372372+373373+```bash
374374+php artisan signal:consume --cursor=123456789
375375+```
376376+377377+## What's Next?
378378+379379+You now know the basics of building Signals! Explore more advanced topics:
380380+381381+- **[Signal Architecture](signals.md)** - Deep dive into Signal structure
382382+- **[Advanced Filtering](filtering.md)** - Master collection patterns and wildcards
383383+- **[Jetstream vs Firehose](modes.md)** - Choose the right mode for your use case
384384+- **[Queue Integration](queues.md)** - Build high-performance processors
385385+- **[Real-World Examples](examples.md)** - Learn from production use cases
386386+387387+## Getting Help
388388+389389+- Check the [examples documentation](examples.md) for more patterns
390390+- Review the [configuration guide](configuration.md) for all options
391391+- Open an issue on GitHub if you encounter problems
+702
docs/signals.md
···11+# Creating Signals
22+33+Signals are the heart of the Signal package. They define how your application responds to AT Protocol events.
44+55+## What is a Signal?
66+77+A **Signal** is a PHP class that:
88+99+1. Listens for specific types of AT Protocol events
1010+2. Filters those events based on your criteria
1111+3. Executes custom logic when matching events arrive
1212+1313+Think of Signals like Laravel event listeners, but specifically designed for the AT Protocol.
1414+1515+## Basic Signal Structure
1616+1717+Every Signal extends the base `Signal` class:
1818+1919+```php
2020+<?php
2121+2222+namespace App\Signals;
2323+2424+use SocialDept\Signal\Events\SignalEvent;
2525+use SocialDept\Signal\Signals\Signal;
2626+2727+class MySignal extends Signal
2828+{
2929+ /**
3030+ * Define which event types to listen for.
3131+ * Required.
3232+ */
3333+ public function eventTypes(): array
3434+ {
3535+ return ['commit'];
3636+ }
3737+3838+ /**
3939+ * Handle the event when it arrives.
4040+ * Required.
4141+ */
4242+ public function handle(SignalEvent $event): void
4343+ {
4444+ // Your logic here
4545+ }
4646+}
4747+```
4848+4949+Only two methods are required:
5050+- `eventTypes()` - Which event types to listen for
5151+- `handle()` - What to do when events arrive
5252+5353+## Creating Signals
5454+5555+### Using Artisan (Recommended)
5656+5757+Generate a new Signal with the make command:
5858+5959+```bash
6060+php artisan make:signal MySignal
6161+```
6262+6363+This creates `app/Signals/MySignal.php` with a basic template.
6464+6565+#### With Options
6666+6767+Generate a Signal with pre-configured filters:
6868+6969+```bash
7070+# Create a Signal for posts only
7171+php artisan make:signal PostSignal --type=commit --collection=app.bsky.feed.post
7272+7373+# Create a Signal for follows
7474+php artisan make:signal FollowSignal --type=commit --collection=app.bsky.graph.follow
7575+```
7676+7777+### Manual Creation
7878+7979+You can also create Signals manually in `app/Signals/`:
8080+8181+```php
8282+<?php
8383+8484+namespace App\Signals;
8585+8686+use SocialDept\Signal\Events\SignalEvent;
8787+use SocialDept\Signal\Signals\Signal;
8888+8989+class ManualSignal extends Signal
9090+{
9191+ public function eventTypes(): array
9292+ {
9393+ return ['commit'];
9494+ }
9595+9696+ public function handle(SignalEvent $event): void
9797+ {
9898+ //
9999+ }
100100+}
101101+```
102102+103103+Signals are automatically discovered from `app/Signals/` - no registration needed.
104104+105105+## Event Types
106106+107107+Signals can listen for three types of AT Protocol events:
108108+109109+### Commit Events
110110+111111+Repository commits represent changes to user data:
112112+113113+```php
114114+use SocialDept\Signal\Enums\SignalEventType;
115115+116116+public function eventTypes(): array
117117+{
118118+ return [SignalEventType::Commit];
119119+ // Or: return ['commit'];
120120+}
121121+```
122122+123123+**Common commit events:**
124124+- Creating posts, likes, follows, reposts
125125+- Updating profile information
126126+- Deleting content
127127+128128+This is the most common event type and what you'll use 99% of the time.
129129+130130+### Identity Events
131131+132132+Identity changes track handle updates:
133133+134134+```php
135135+public function eventTypes(): array
136136+{
137137+ return [SignalEventType::Identity];
138138+ // Or: return ['identity'];
139139+}
140140+```
141141+142142+**Use cases:**
143143+- Tracking handle changes
144144+- Updating local user records
145145+- Monitoring account migrations
146146+147147+### Account Events
148148+149149+Account status changes track account state:
150150+151151+```php
152152+public function eventTypes(): array
153153+{
154154+ return [SignalEventType::Account];
155155+ // Or: return ['account'];
156156+}
157157+```
158158+159159+**Use cases:**
160160+- Detecting account deactivation
161161+- Monitoring account status
162162+- Compliance tracking
163163+164164+### Multiple Event Types
165165+166166+Listen to multiple event types in one Signal:
167167+168168+```php
169169+public function eventTypes(): array
170170+{
171171+ return [
172172+ SignalEventType::Commit,
173173+ SignalEventType::Identity,
174174+ ];
175175+}
176176+177177+public function handle(SignalEvent $event): void
178178+{
179179+ if ($event->isCommit()) {
180180+ // Handle commit
181181+ }
182182+183183+ if ($event->isIdentity()) {
184184+ // Handle identity change
185185+ }
186186+}
187187+```
188188+189189+## The SignalEvent Object
190190+191191+The `SignalEvent` object contains all event data:
192192+193193+### Common Properties
194194+195195+```php
196196+public function handle(SignalEvent $event): void
197197+{
198198+ // User's DID (decentralized identifier)
199199+ $did = $event->did; // "did:plc:z72i7hdynmk6r22z27h6tvur"
200200+201201+ // Event type (commit, identity, account)
202202+ $kind = $event->kind;
203203+204204+ // Timestamp in microseconds
205205+ $timestamp = $event->timeUs;
206206+207207+ // Convert to Carbon instance
208208+ $date = $event->getTimestamp();
209209+}
210210+```
211211+212212+### Commit Events
213213+214214+For commit events, access the `commit` property:
215215+216216+```php
217217+public function handle(SignalEvent $event): void
218218+{
219219+ if ($event->isCommit()) {
220220+ // Collection (e.g., "app.bsky.feed.post")
221221+ $collection = $event->commit->collection;
222222+ // Or: $collection = $event->getCollection();
223223+224224+ // Operation (create, update, delete)
225225+ $operation = $event->commit->operation;
226226+ // Or: $operation = $event->getOperation();
227227+228228+ // Record key (unique identifier)
229229+ $rkey = $event->commit->rkey;
230230+231231+ // Revision
232232+ $rev = $event->commit->rev;
233233+234234+ // The actual record data
235235+ $record = $event->commit->record;
236236+ // Or: $record = $event->getRecord();
237237+ }
238238+}
239239+```
240240+241241+### Working with Records
242242+243243+Records contain the actual data (posts, likes, etc.):
244244+245245+```php
246246+public function handle(SignalEvent $event): void
247247+{
248248+ $record = $event->getRecord();
249249+250250+ // For posts (app.bsky.feed.post)
251251+ $text = $record->text ?? null;
252252+ $createdAt = $record->createdAt ?? null;
253253+ $embed = $record->embed ?? null;
254254+ $facets = $record->facets ?? null;
255255+256256+ // For likes (app.bsky.feed.like)
257257+ $subject = $record->subject ?? null;
258258+259259+ // For follows (app.bsky.graph.follow)
260260+ $subject = $record->subject ?? null;
261261+}
262262+```
263263+264264+Records are `stdClass` objects, so use null coalescing (`??`) for safety.
265265+266266+### Identity Events
267267+268268+For identity events, access the `identity` property:
269269+270270+```php
271271+public function handle(SignalEvent $event): void
272272+{
273273+ if ($event->isIdentity()) {
274274+ // New handle
275275+ $handle = $event->identity->handle;
276276+277277+ // User's DID
278278+ $did = $event->did;
279279+280280+ // Sequence number
281281+ $seq = $event->identity->seq;
282282+283283+ // Timestamp
284284+ $time = $event->identity->time;
285285+ }
286286+}
287287+```
288288+289289+### Account Events
290290+291291+For account events, access the `account` property:
292292+293293+```php
294294+public function handle(SignalEvent $event): void
295295+{
296296+ if ($event->isAccount()) {
297297+ // Account status
298298+ $active = $event->account->active; // true/false
299299+300300+ // Status reason
301301+ $status = $event->account->status ?? null;
302302+303303+ // User's DID
304304+ $did = $event->did;
305305+306306+ // Sequence number
307307+ $seq = $event->account->seq;
308308+309309+ // Timestamp
310310+ $time = $event->account->time;
311311+ }
312312+}
313313+```
314314+315315+## Helper Methods
316316+317317+Signals provide several helper methods for common tasks:
318318+319319+### Type Checking
320320+321321+```php
322322+public function handle(SignalEvent $event): void
323323+{
324324+ // Check event type
325325+ if ($event->isCommit()) {
326326+ //
327327+ }
328328+329329+ if ($event->isIdentity()) {
330330+ //
331331+ }
332332+333333+ if ($event->isAccount()) {
334334+ //
335335+ }
336336+}
337337+```
338338+339339+### Operation Checking (Commit Events)
340340+341341+```php
342342+use SocialDept\Signal\Enums\SignalCommitOperation;
343343+344344+public function handle(SignalEvent $event): void
345345+{
346346+ $operation = $event->getOperation();
347347+348348+ // Using enum
349349+ if ($operation === SignalCommitOperation::Create) {
350350+ // Handle new records
351351+ }
352352+353353+ // Using commit helper
354354+ if ($event->commit->isCreate()) {
355355+ // Handle new records
356356+ }
357357+358358+ if ($event->commit->isUpdate()) {
359359+ // Handle updates
360360+ }
361361+362362+ if ($event->commit->isDelete()) {
363363+ // Handle deletions
364364+ }
365365+}
366366+```
367367+368368+### Data Extraction
369369+370370+```php
371371+public function handle(SignalEvent $event): void
372372+{
373373+ // Get collection (commit events only)
374374+ $collection = $event->getCollection();
375375+376376+ // Get operation (commit events only)
377377+ $operation = $event->getOperation();
378378+379379+ // Get record (commit events only)
380380+ $record = $event->getRecord();
381381+382382+ // Get timestamp as Carbon
383383+ $timestamp = $event->getTimestamp();
384384+385385+ // Convert to array
386386+ $array = $event->toArray();
387387+}
388388+```
389389+390390+## Optional Signal Methods
391391+392392+Signals support several optional methods for advanced behavior:
393393+394394+### Collections Filter
395395+396396+Filter by AT Protocol collections:
397397+398398+```php
399399+public function collections(): ?array
400400+{
401401+ return ['app.bsky.feed.post'];
402402+}
403403+```
404404+405405+Return `null` to handle all collections.
406406+407407+[Learn more about collection filtering →](filtering.md)
408408+409409+### Operations Filter
410410+411411+Filter by operation type (commit events only):
412412+413413+```php
414414+public function operations(): ?array
415415+{
416416+ return [SignalCommitOperation::Create];
417417+}
418418+```
419419+420420+Return `null` to handle all operations.
421421+422422+[Learn more about operation filtering →](filtering.md)
423423+424424+### DIDs Filter
425425+426426+Filter by specific users:
427427+428428+```php
429429+public function dids(): ?array
430430+{
431431+ return [
432432+ 'did:plc:z72i7hdynmk6r22z27h6tvur',
433433+ ];
434434+}
435435+```
436436+437437+Return `null` to handle all users.
438438+439439+[Learn more about DID filtering →](filtering.md)
440440+441441+### Custom Filtering
442442+443443+Add complex filtering logic:
444444+445445+```php
446446+public function shouldHandle(SignalEvent $event): bool
447447+{
448448+ // Only handle posts with images
449449+ if ($event->isCommit() && $event->getCollection() === 'app.bsky.feed.post') {
450450+ $record = $event->getRecord();
451451+ return isset($record->embed);
452452+ }
453453+454454+ return true;
455455+}
456456+```
457457+458458+### Queue Configuration
459459+460460+Process events asynchronously:
461461+462462+```php
463463+public function shouldQueue(): bool
464464+{
465465+ return true;
466466+}
467467+468468+public function queue(): string
469469+{
470470+ return 'high-priority';
471471+}
472472+473473+public function queueConnection(): string
474474+{
475475+ return 'redis';
476476+}
477477+```
478478+479479+[Learn more about queue integration →](queues.md)
480480+481481+### Failure Handling
482482+483483+Handle processing failures:
484484+485485+```php
486486+public function failed(SignalEvent $event, \Throwable $exception): void
487487+{
488488+ Log::error('Signal failed', [
489489+ 'signal' => static::class,
490490+ 'event' => $event->toArray(),
491491+ 'error' => $exception->getMessage(),
492492+ ]);
493493+}
494494+```
495495+496496+## Signal Lifecycle
497497+498498+Understanding the Signal lifecycle helps you write better Signals:
499499+500500+### 1. Event Arrives
501501+502502+An event arrives from the AT Protocol (via Jetstream or Firehose).
503503+504504+### 2. Event Type Matching
505505+506506+Signal checks if the event type matches your `eventTypes()` definition.
507507+508508+### 3. Collection Filtering
509509+510510+If defined, Signal checks if the collection matches your `collections()` definition.
511511+512512+### 4. Operation Filtering
513513+514514+If defined, Signal checks if the operation matches your `operations()` definition.
515515+516516+### 5. DID Filtering
517517+518518+If defined, Signal checks if the DID matches your `dids()` definition.
519519+520520+### 6. Custom Filtering
521521+522522+If defined, Signal calls your `shouldHandle()` method.
523523+524524+### 7. Queue Decision
525525+526526+Signal checks `shouldQueue()` to determine if the event should be queued.
527527+528528+### 8. Handler Execution
529529+530530+Your `handle()` method is called (either synchronously or via queue).
531531+532532+### 9. Failure Handling (if applicable)
533533+534534+If an exception occurs, your `failed()` method is called (if defined).
535535+536536+## Best Practices
537537+538538+### Keep Handlers Focused
539539+540540+Each Signal should do one thing well:
541541+542542+```php
543543+// Good - focused on one task
544544+class TrackNewPostsSignal extends Signal
545545+{
546546+ public function collections(): ?array
547547+ {
548548+ return ['app.bsky.feed.post'];
549549+ }
550550+551551+ public function handle(SignalEvent $event): void
552552+ {
553553+ $this->storePost($event);
554554+ }
555555+}
556556+557557+// Less ideal - doing too much
558558+class MonitorEverythingSignal extends Signal
559559+{
560560+ public function handle(SignalEvent $event): void
561561+ {
562562+ $this->storePost($event);
563563+ $this->sendNotification($event);
564564+ $this->updateAnalytics($event);
565565+ $this->processRecommendations($event);
566566+ }
567567+}
568568+```
569569+570570+### Use Queues for Heavy Work
571571+572572+Don't block the consumer with expensive operations:
573573+574574+```php
575575+class AnalyzePostSignal extends Signal
576576+{
577577+ public function shouldQueue(): bool
578578+ {
579579+ return true; // Process in background
580580+ }
581581+582582+ public function handle(SignalEvent $event): void
583583+ {
584584+ $this->performExpensiveAnalysis($event);
585585+ }
586586+}
587587+```
588588+589589+### Validate Data Safely
590590+591591+Records can have missing or unexpected data:
592592+593593+```php
594594+public function handle(SignalEvent $event): void
595595+{
596596+ $record = $event->getRecord();
597597+598598+ // Use null coalescing
599599+ $text = $record->text ?? '';
600600+601601+ // Validate before processing
602602+ if (empty($text)) {
603603+ return;
604604+ }
605605+606606+ // Safe to process
607607+ $this->processText($text);
608608+}
609609+```
610610+611611+### Add Logging
612612+613613+Log important events for debugging:
614614+615615+```php
616616+public function handle(SignalEvent $event): void
617617+{
618618+ Log::debug('Processing event', [
619619+ 'signal' => static::class,
620620+ 'collection' => $event->getCollection(),
621621+ 'operation' => $event->getOperation()->value,
622622+ ]);
623623+624624+ // Your logic
625625+}
626626+```
627627+628628+### Handle Failures Gracefully
629629+630630+Always implement failure handling for queued Signals:
631631+632632+```php
633633+public function failed(SignalEvent $event, \Throwable $exception): void
634634+{
635635+ Log::error('Signal processing failed', [
636636+ 'signal' => static::class,
637637+ 'event_did' => $event->did,
638638+ 'error' => $exception->getMessage(),
639639+ 'trace' => $exception->getTraceAsString(),
640640+ ]);
641641+642642+ // Optionally: send to error tracking service
643643+ // report($exception);
644644+}
645645+```
646646+647647+## Auto-Discovery
648648+649649+Signals are automatically discovered from `app/Signals/` by default. You can customize discovery in `config/signal.php`:
650650+651651+```php
652652+'auto_discovery' => [
653653+ 'enabled' => true,
654654+ 'path' => app_path('Signals'),
655655+ 'namespace' => 'App\\Signals',
656656+],
657657+```
658658+659659+### Manual Registration
660660+661661+Disable auto-discovery and register Signals manually:
662662+663663+```php
664664+'auto_discovery' => [
665665+ 'enabled' => false,
666666+],
667667+668668+'signals' => [
669669+ \App\Signals\NewPostSignal::class,
670670+ \App\Signals\NewFollowSignal::class,
671671+],
672672+```
673673+674674+## Testing Signals
675675+676676+Test your Signals before deploying:
677677+678678+```bash
679679+php artisan signal:test MySignal
680680+```
681681+682682+[Learn more about testing →](testing.md)
683683+684684+## Listing Signals
685685+686686+View all registered Signals:
687687+688688+```bash
689689+php artisan signal:list
690690+```
691691+692692+This displays:
693693+- Signal class names
694694+- Event types they listen for
695695+- Collection filters (if any)
696696+- Queue configuration
697697+698698+## Next Steps
699699+700700+- **[Learn about filtering →](filtering.md)** - Master collection patterns and wildcards
701701+- **[Understand queue integration →](queues.md)** - Build high-performance processors
702702+- **[See real-world examples →](examples.md)** - Learn from production use cases
+728
docs/testing.md
···11+# Testing Signals
22+33+Testing your Signals ensures they behave correctly before deploying to production. Signal provides tools for both manual and automated testing.
44+55+## Quick Testing with Artisan
66+77+The fastest way to test a Signal is with the `signal:test` command.
88+99+### Test a Signal
1010+1111+```bash
1212+php artisan signal:test NewPostSignal
1313+```
1414+1515+This runs your Signal with sample event data and displays the output.
1616+1717+### What It Does
1818+1919+1. Creates a sample `SignalEvent` matching your Signal's filters
2020+2. Calls your Signal's `handle()` method
2121+3. Displays output, logs, and any errors
2222+4. Shows execution time
2323+2424+### Example Output
2525+2626+```
2727+Testing Signal: App\Signals\NewPostSignal
2828+2929+Creating sample commit event for collection: app.bsky.feed.post
3030+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3131+3232+Event Details:
3333+ DID: did:plc:test123
3434+ Collection: app.bsky.feed.post
3535+ Operation: create
3636+ Text: Sample post for testing
3737+3838+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3939+4040+Processing event...
4141+✓ Signal processed successfully
4242+4343+Execution time: 12ms
4444+```
4545+4646+### Limitations
4747+4848+- Uses sample data (not real events)
4949+- Doesn't test filtering logic comprehensively
5050+- Can't test queue behavior
5151+- Limited to basic scenarios
5252+5353+For comprehensive testing, write automated tests.
5454+5555+## Unit Testing
5656+5757+Test your Signals in isolation.
5858+5959+### Basic Test Structure
6060+6161+```php
6262+<?php
6363+6464+namespace Tests\Unit\Signals;
6565+6666+use App\Signals\NewPostSignal;
6767+use SocialDept\Signal\Events\CommitEvent;
6868+use SocialDept\Signal\Events\SignalEvent;
6969+use Tests\TestCase;
7070+7171+class NewPostSignalTest extends TestCase
7272+{
7373+ /** @test */
7474+ public function it_handles_new_posts()
7575+ {
7676+ $signal = new NewPostSignal();
7777+7878+ $event = new SignalEvent(
7979+ did: 'did:plc:test123',
8080+ timeUs: time() * 1000000,
8181+ kind: 'commit',
8282+ commit: new CommitEvent(
8383+ rev: 'test',
8484+ operation: 'create',
8585+ collection: 'app.bsky.feed.post',
8686+ rkey: 'test123',
8787+ record: (object) [
8888+ 'text' => 'Hello World!',
8989+ 'createdAt' => now()->toIso8601String(),
9090+ ],
9191+ ),
9292+ );
9393+9494+ $signal->handle($event);
9595+9696+ // Assert expected behavior
9797+ $this->assertDatabaseHas('posts', [
9898+ 'text' => 'Hello World!',
9999+ ]);
100100+ }
101101+}
102102+```
103103+104104+### Testing Event Types
105105+106106+Verify your Signal listens for correct event types:
107107+108108+```php
109109+/** @test */
110110+public function it_listens_for_commit_events()
111111+{
112112+ $signal = new NewPostSignal();
113113+114114+ $eventTypes = $signal->eventTypes();
115115+116116+ $this->assertContains('commit', $eventTypes);
117117+}
118118+```
119119+120120+### Testing Filters
121121+122122+Verify collection filtering:
123123+124124+```php
125125+/** @test */
126126+public function it_filters_to_posts_only()
127127+{
128128+ $signal = new NewPostSignal();
129129+130130+ $collections = $signal->collections();
131131+132132+ $this->assertEquals(['app.bsky.feed.post'], $collections);
133133+}
134134+```
135135+136136+### Testing Operation Filtering
137137+138138+```php
139139+/** @test */
140140+public function it_only_handles_creates()
141141+{
142142+ $signal = new NewPostSignal();
143143+144144+ $operations = $signal->operations();
145145+146146+ $this->assertEquals([SignalCommitOperation::Create], $operations);
147147+}
148148+```
149149+150150+### Testing Custom Filtering
151151+152152+```php
153153+/** @test */
154154+public function it_filters_posts_with_images()
155155+{
156156+ $signal = new ImagePostSignal();
157157+158158+ // Event with image
159159+ $eventWithImage = $this->createEvent([
160160+ 'text' => 'Check this out!',
161161+ 'embed' => (object) ['type' => 'image'],
162162+ ]);
163163+164164+ $this->assertTrue($signal->shouldHandle($eventWithImage));
165165+166166+ // Event without image
167167+ $eventWithoutImage = $this->createEvent([
168168+ 'text' => 'Just text',
169169+ ]);
170170+171171+ $this->assertFalse($signal->shouldHandle($eventWithoutImage));
172172+}
173173+```
174174+175175+## Feature Testing
176176+177177+Test Signals in the context of your application.
178178+179179+### Test with Database
180180+181181+```php
182182+<?php
183183+184184+namespace Tests\Feature\Signals;
185185+186186+use App\Models\Post;
187187+use App\Signals\StorePostSignal;
188188+use Illuminate\Foundation\Testing\RefreshDatabase;
189189+use SocialDept\Signal\Events\CommitEvent;
190190+use SocialDept\Signal\Events\SignalEvent;
191191+use Tests\TestCase;
192192+193193+class StorePostSignalTest extends TestCase
194194+{
195195+ use RefreshDatabase;
196196+197197+ /** @test */
198198+ public function it_stores_posts_in_database()
199199+ {
200200+ $signal = new StorePostSignal();
201201+202202+ $event = new SignalEvent(
203203+ did: 'did:plc:test123',
204204+ timeUs: time() * 1000000,
205205+ kind: 'commit',
206206+ commit: new CommitEvent(
207207+ rev: 'abc',
208208+ operation: 'create',
209209+ collection: 'app.bsky.feed.post',
210210+ rkey: 'test',
211211+ record: (object) [
212212+ 'text' => 'Test post',
213213+ 'createdAt' => now()->toIso8601String(),
214214+ ],
215215+ ),
216216+ );
217217+218218+ $signal->handle($event);
219219+220220+ $this->assertDatabaseHas('posts', [
221221+ 'did' => 'did:plc:test123',
222222+ 'text' => 'Test post',
223223+ ]);
224224+ }
225225+226226+ /** @test */
227227+ public function it_updates_existing_posts()
228228+ {
229229+ Post::create([
230230+ 'did' => 'did:plc:test123',
231231+ 'rkey' => 'test',
232232+ 'text' => 'Old text',
233233+ ]);
234234+235235+ $signal = new StorePostSignal();
236236+237237+ $event = $this->createUpdateEvent([
238238+ 'text' => 'New text',
239239+ ]);
240240+241241+ $signal->handle($event);
242242+243243+ $this->assertDatabaseHas('posts', [
244244+ 'did' => 'did:plc:test123',
245245+ 'text' => 'New text',
246246+ ]);
247247+248248+ $this->assertEquals(1, Post::count());
249249+ }
250250+251251+ /** @test */
252252+ public function it_deletes_posts()
253253+ {
254254+ Post::create([
255255+ 'did' => 'did:plc:test123',
256256+ 'rkey' => 'test',
257257+ 'text' => 'Test post',
258258+ ]);
259259+260260+ $signal = new StorePostSignal();
261261+262262+ $event = $this->createDeleteEvent();
263263+264264+ $signal->handle($event);
265265+266266+ $this->assertDatabaseMissing('posts', [
267267+ 'did' => 'did:plc:test123',
268268+ 'rkey' => 'test',
269269+ ]);
270270+ }
271271+}
272272+```
273273+274274+### Test with External APIs
275275+276276+```php
277277+use Illuminate\Support\Facades\Http;
278278+279279+/** @test */
280280+public function it_sends_notifications()
281281+{
282282+ Http::fake();
283283+284284+ $signal = new NotificationSignal();
285285+286286+ $event = $this->createEvent();
287287+288288+ $signal->handle($event);
289289+290290+ Http::assertSent(function ($request) {
291291+ return $request->url() === 'https://api.example.com/notify' &&
292292+ $request['text'] === 'New post created';
293293+ });
294294+}
295295+```
296296+297297+## Testing Queued Signals
298298+299299+Test Signals that use queues.
300300+301301+### Test Queue Dispatch
302302+303303+```php
304304+use Illuminate\Support\Facades\Queue;
305305+306306+/** @test */
307307+public function it_queues_events()
308308+{
309309+ Queue::fake();
310310+311311+ $signal = new QueuedSignal();
312312+313313+ $this->assertTrue($signal->shouldQueue());
314314+315315+ // In production, this would queue
316316+ // For testing, we verify the intent
317317+}
318318+```
319319+320320+### Test with Sync Queue
321321+322322+Process queued jobs synchronously in tests:
323323+324324+```php
325325+/** @test */
326326+public function it_processes_queued_events()
327327+{
328328+ // Use sync queue for immediate processing
329329+ config(['queue.default' => 'sync']);
330330+331331+ $signal = new QueuedSignal();
332332+333333+ $event = $this->createEvent();
334334+335335+ $signal->handle($event);
336336+337337+ // Assert side effects happened
338338+ $this->assertDatabaseHas('posts', [...]);
339339+}
340340+```
341341+342342+### Test Queue Configuration
343343+344344+```php
345345+/** @test */
346346+public function it_uses_high_priority_queue()
347347+{
348348+ $signal = new HighPrioritySignal();
349349+350350+ $this->assertTrue($signal->shouldQueue());
351351+ $this->assertEquals('high-priority', $signal->queue());
352352+}
353353+354354+/** @test */
355355+public function it_uses_redis_connection()
356356+{
357357+ $signal = new RedisQueueSignal();
358358+359359+ $this->assertEquals('redis', $signal->queueConnection());
360360+}
361361+```
362362+363363+## Testing Failure Handling
364364+365365+Test how your Signal handles errors.
366366+367367+### Test Failed Method
368368+369369+```php
370370+use Illuminate\Support\Facades\Log;
371371+372372+/** @test */
373373+public function it_logs_failures()
374374+{
375375+ Log::spy();
376376+377377+ $signal = new FailureHandlingSignal();
378378+379379+ $event = $this->createEvent();
380380+ $exception = new \Exception('Something went wrong');
381381+382382+ $signal->failed($event, $exception);
383383+384384+ Log::shouldHaveReceived('error')
385385+ ->with('Signal failed', \Mockery::any());
386386+}
387387+```
388388+389389+### Test Exception Handling
390390+391391+```php
392392+/** @test */
393393+public function it_handles_invalid_data_gracefully()
394394+{
395395+ $signal = new RobustSignal();
396396+397397+ $event = new SignalEvent(
398398+ did: 'did:plc:test',
399399+ timeUs: time() * 1000000,
400400+ kind: 'commit',
401401+ commit: new CommitEvent(
402402+ rev: 'test',
403403+ operation: 'create',
404404+ collection: 'app.bsky.feed.post',
405405+ rkey: 'test',
406406+ record: (object) [], // Missing required fields
407407+ ),
408408+ );
409409+410410+ // Should not throw
411411+ $signal->handle($event);
412412+413413+ // Should handle gracefully (e.g., log and skip)
414414+ $this->assertDatabaseCount('posts', 0);
415415+}
416416+```
417417+418418+## Test Helpers
419419+420420+Create reusable helpers for common test scenarios.
421421+422422+### Event Factory Helper
423423+424424+```php
425425+trait CreatesSignalEvents
426426+{
427427+ protected function createCommitEvent(array $overrides = []): SignalEvent
428428+ {
429429+ $defaults = [
430430+ 'did' => 'did:plc:test123',
431431+ 'timeUs' => time() * 1000000,
432432+ 'kind' => 'commit',
433433+ 'commit' => new CommitEvent(
434434+ rev: 'test',
435435+ operation: $overrides['operation'] ?? 'create',
436436+ collection: $overrides['collection'] ?? 'app.bsky.feed.post',
437437+ rkey: $overrides['rkey'] ?? 'test',
438438+ record: (object) array_merge([
439439+ 'text' => 'Test post',
440440+ 'createdAt' => now()->toIso8601String(),
441441+ ], $overrides['record'] ?? []),
442442+ ),
443443+ ];
444444+445445+ return new SignalEvent(...$defaults);
446446+ }
447447+448448+ protected function createPostEvent(array $record = []): SignalEvent
449449+ {
450450+ return $this->createCommitEvent([
451451+ 'collection' => 'app.bsky.feed.post',
452452+ 'record' => $record,
453453+ ]);
454454+ }
455455+456456+ protected function createLikeEvent(array $record = []): SignalEvent
457457+ {
458458+ return $this->createCommitEvent([
459459+ 'collection' => 'app.bsky.feed.like',
460460+ 'record' => array_merge([
461461+ 'subject' => (object) [
462462+ 'uri' => 'at://did:plc:test/app.bsky.feed.post/test',
463463+ 'cid' => 'bafytest',
464464+ ],
465465+ 'createdAt' => now()->toIso8601String(),
466466+ ], $record),
467467+ ]);
468468+ }
469469+470470+ protected function createFollowEvent(array $record = []): SignalEvent
471471+ {
472472+ return $this->createCommitEvent([
473473+ 'collection' => 'app.bsky.graph.follow',
474474+ 'record' => array_merge([
475475+ 'subject' => 'did:plc:target',
476476+ 'createdAt' => now()->toIso8601String(),
477477+ ], $record),
478478+ ]);
479479+ }
480480+}
481481+```
482482+483483+Use in tests:
484484+485485+```php
486486+class MySignalTest extends TestCase
487487+{
488488+ use CreatesSignalEvents;
489489+490490+ /** @test */
491491+ public function it_handles_posts()
492492+ {
493493+ $event = $this->createPostEvent([
494494+ 'text' => 'Custom text',
495495+ ]);
496496+497497+ // Test with event
498498+ }
499499+}
500500+```
501501+502502+### Signal Factory Helper
503503+504504+```php
505505+trait CreatesSignals
506506+{
507507+ protected function createSignal(string $class, array $config = [])
508508+ {
509509+ $signal = new $class();
510510+511511+ // Override configuration for testing
512512+ foreach ($config as $method => $value) {
513513+ $signal->{$method} = $value;
514514+ }
515515+516516+ return $signal;
517517+ }
518518+}
519519+```
520520+521521+## Testing Best Practices
522522+523523+### Use Descriptive Test Names
524524+525525+```php
526526+// Good
527527+/** @test */
528528+public function it_stores_posts_with_valid_data()
529529+530530+/** @test */
531531+public function it_skips_posts_without_text()
532532+533533+/** @test */
534534+public function it_handles_duplicate_posts_gracefully()
535535+536536+// Less descriptive
537537+/** @test */
538538+public function test_handle()
539539+```
540540+541541+### Test Edge Cases
542542+543543+```php
544544+/** @test */
545545+public function it_handles_empty_text()
546546+{
547547+ $event = $this->createPostEvent(['text' => '']);
548548+ // Test behavior
549549+}
550550+551551+/** @test */
552552+public function it_handles_very_long_text()
553553+{
554554+ $event = $this->createPostEvent(['text' => str_repeat('a', 10000)]);
555555+ // Test behavior
556556+}
557557+558558+/** @test */
559559+public function it_handles_missing_created_at()
560560+{
561561+ $event = $this->createPostEvent(['createdAt' => null]);
562562+ // Test behavior
563563+}
564564+```
565565+566566+### Test All Operations
567567+568568+```php
569569+/** @test */
570570+public function it_handles_creates()
571571+{
572572+ $event = $this->createEvent(['operation' => 'create']);
573573+ // Test
574574+}
575575+576576+/** @test */
577577+public function it_handles_updates()
578578+{
579579+ $event = $this->createEvent(['operation' => 'update']);
580580+ // Test
581581+}
582582+583583+/** @test */
584584+public function it_handles_deletes()
585585+{
586586+ $event = $this->createEvent(['operation' => 'delete']);
587587+ // Test
588588+}
589589+```
590590+591591+### Mock External Dependencies
592592+593593+```php
594594+/** @test */
595595+public function it_calls_external_api()
596596+{
597597+ Http::fake([
598598+ 'api.example.com/*' => Http::response(['success' => true]),
599599+ ]);
600600+601601+ $signal = new ApiSignal();
602602+ $event = $this->createEvent();
603603+604604+ $signal->handle($event);
605605+606606+ Http::assertSent(function ($request) {
607607+ return $request->url() === 'https://api.example.com/endpoint';
608608+ });
609609+}
610610+```
611611+612612+### Test Database State
613613+614614+```php
615615+use Illuminate\Foundation\Testing\RefreshDatabase;
616616+617617+class DatabaseSignalTest extends TestCase
618618+{
619619+ use RefreshDatabase;
620620+621621+ /** @test */
622622+ public function it_creates_records()
623623+ {
624624+ // Fresh database for each test
625625+ }
626626+}
627627+```
628628+629629+## Continuous Integration
630630+631631+Run tests automatically on every commit.
632632+633633+### GitHub Actions
634634+635635+```yaml
636636+# .github/workflows/tests.yml
637637+name: Tests
638638+639639+on: [push, pull_request]
640640+641641+jobs:
642642+ test:
643643+ runs-on: ubuntu-latest
644644+645645+ steps:
646646+ - uses: actions/checkout@v2
647647+648648+ - name: Setup PHP
649649+ uses: shivammathur/setup-php@v2
650650+ with:
651651+ php-version: 8.2
652652+653653+ - name: Install Dependencies
654654+ run: composer install
655655+656656+ - name: Run Tests
657657+ run: php artisan test
658658+```
659659+660660+### Run Signal-Specific Tests
661661+662662+```bash
663663+# Run all Signal tests
664664+php artisan test --testsuite=Signals
665665+666666+# Run specific test file
667667+php artisan test tests/Unit/Signals/NewPostSignalTest.php
668668+669669+# Run with coverage
670670+php artisan test --coverage
671671+```
672672+673673+## Debugging Tests
674674+675675+### Enable Debug Output
676676+677677+```php
678678+/** @test */
679679+public function it_processes_events()
680680+{
681681+ $signal = new NewPostSignal();
682682+683683+ $event = $this->createEvent();
684684+685685+ dump($event); // Output event data
686686+687687+ $signal->handle($event);
688688+689689+ dump(Post::all()); // Output results
690690+}
691691+```
692692+693693+### Use dd() to Stop Execution
694694+695695+```php
696696+/** @test */
697697+public function it_processes_events()
698698+{
699699+ $event = $this->createEvent();
700700+701701+ dd($event); // Dump and die
702702+703703+ // This won't run
704704+}
705705+```
706706+707707+### Check Logs
708708+709709+```php
710710+/** @test */
711711+public function it_logs_processing()
712712+{
713713+ Log::spy();
714714+715715+ $signal = new LoggingSignal();
716716+ $event = $this->createEvent();
717717+718718+ $signal->handle($event);
719719+720720+ Log::shouldHaveReceived('info')->once();
721721+}
722722+```
723723+724724+## Next Steps
725725+726726+- **[See real-world examples →](examples.md)** - Learn from production test patterns
727727+- **[Review queue integration →](queues.md)** - Test queued Signals
728728+- **[Review signals documentation →](signals.md)** - Understand Signal structure