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

Merge pull request #6 from socialdept/dev

Rewrite documentation with guides and contribution guidelines

authored by Miguel Batres and committed by GitHub 99e9d919 8322e9d9

+67
CONTRIBUTING.md
··· 1 + # Contributing 2 + 3 + Contributions are **welcome** and will be fully **credited**. 4 + 5 + ## Etiquette 6 + 7 + 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. 8 + 9 + Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the world that developers are civilized and selfless people. 10 + 11 + 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. 12 + 13 + ## Viability 14 + 15 + 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. 16 + 17 + ## Procedure 18 + 19 + ### Before Filing an Issue 20 + 21 + - Search existing issues to avoid duplicates 22 + - Check the [documentation](docs/) to ensure it's not a usage question 23 + - Provide a clear title and description 24 + - Include steps to reproduce the issue 25 + - Specify your environment (PHP version, Laravel version, Signal version, mode) 26 + - Include relevant code samples and full error messages 27 + 28 + ### Before Submitting a Pull Request 29 + 30 + - **Discuss non-trivial changes first** by opening an issue 31 + - **Fork the repository** and create a feature branch from `main` 32 + - **Follow all requirements** listed below 33 + - **Write tests** for your changes 34 + - **Update documentation** if behavior changes 35 + - **Run code style checks** with `vendor/bin/php-cs-fixer fix` 36 + - **Ensure all tests pass** with `vendor/bin/phpunit` 37 + - **Write clear commit messages** that explain what and why 38 + 39 + ## Requirements 40 + 41 + - **[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. 42 + 43 + - **Add tests** - Your patch won't be accepted if it doesn't have tests. All tests must use [PHPUnit](https://phpunit.de/). 44 + 45 + - **Document any change in behaviour** - Make sure the `README.md`, `docs/`, and any other relevant documentation are kept up-to-date. 46 + 47 + - **Consider our release cycle** - We follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 48 + 49 + - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 50 + 51 + - **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. 52 + 53 + ## Running Tests 54 + 55 + ```bash 56 + vendor/bin/phpunit 57 + ``` 58 + 59 + ## Code Style 60 + 61 + Signal follows PSR-12 coding standard. Run PHP CS Fixer before submitting: 62 + 63 + ```bash 64 + vendor/bin/php-cs-fixer fix 65 + ``` 66 + 67 + **Happy coding**!
+112 -737
README.md
··· 1 - # Signal 1 + [![Signal Header](./header.png)](https://github.com/socialdept/signal) 2 2 3 - **Laravel package for building Signals that respond to AT Protocol events** 3 + <h3 align="center"> 4 + Consume real-time AT Protocol events in your Laravel application. 5 + </h3> 4 6 5 - 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. 7 + <p align="center"> 8 + <br> 9 + <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> 10 + <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> 11 + <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> 12 + <a href="LICENSE" title="Software License"><img src="https://img.shields.io/github/license/socialdept/signal?style=flat-square"></a> 13 + </p> 6 14 7 15 --- 8 16 9 - ## Features 17 + ## What is Signal? 10 18 11 - - 🔄 **Dual-Mode Support** - Choose between Jetstream (JSON) or Firehose (CBOR/CAR) based on your needs 12 - - 🔌 **WebSocket Connection** - Connect to AT Protocol with automatic reconnection and exponential backoff 13 - - 🎯 **Signal-based Architecture** - Clean, testable event handlers (avoiding Laravel's "listener" naming collision) 14 - - ⭐ **Wildcard Collection Filtering** - Match multiple collections with patterns like `app.bsky.feed.*` 15 - - 🏗️ **AppView Ready** - Full support for custom collections and building AT Protocol AppViews 16 - - 💾 **Cursor Management** - Resume from last position after disconnections (Database, Redis, or File storage) 17 - - ⚡ **Queue Integration** - Process events asynchronously with Laravel queues 18 - - 🔍 **Auto-Discovery** - Automatically find and register Signals in `app/Signals` 19 - - 🧪 **Testing Tools** - Test your Signals with sample data 20 - - 🛠️ **Artisan Commands** - Full CLI support for managing and testing Signals 19 + **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. 21 20 22 - --- 21 + Think of it as Laravel's event listeners, but for the decentralized social web. 23 22 24 - ## Table of Contents 23 + ## Why use Signal? 25 24 26 - <!-- TOC --> 27 - * [Installation](#installation) 28 - * [Quick Start](#quick-start) 29 - * [Jetstream vs Firehose](#jetstream-vs-firehose) 30 - * [Creating Signals](#creating-signals) 31 - * [Filtering Events](#filtering-events) 32 - * [Queue Integration](#queue-integration) 33 - * [Configuration](#configuration-1) 34 - * [Programmatic Usage](#programmatic-usage) 35 - * [Available Commands](#available-commands) 36 - * [Testing](#testing) 37 - * [External Resources](#external-resources) 38 - * [Examples](#examples) 39 - * [Requirements](#requirements) 40 - * [License](#license) 41 - * [Support](#support) 42 - <!-- TOC --> 43 - 44 - --- 45 - 46 - ## Installation 47 - 48 - Install the package via Composer: 25 + - **Laravel-style code** - Familiar patterns you already know 26 + - **Real-time processing** - React to events as they happen 27 + - **Dual-mode support** - Choose Jetstream (efficient JSON) or Firehose (comprehensive CBOR) 28 + - **AppView ready** - Full support for custom collections and protocols 29 + - **Production features** - Queue integration, cursor management, auto-reconnection 30 + - **Easy filtering** - Target specific collections, operations, and users with wildcards 31 + - **Built-in testing** - Test your signals with sample data 49 32 50 - ```bash 51 - composer require socialdept/signal 52 - ``` 53 - 54 - Run the installation command: 55 - 56 - ```bash 57 - php artisan signal:install 58 - ``` 59 - 60 - This will: 61 - - Publish the configuration file to `config/signal.php` 62 - - Publish the database migration 63 - - Run migrations (with confirmation) 64 - - Display next steps 65 - 66 - ### Manual Installation 67 - 68 - If you prefer manual installation: 69 - 70 - ```bash 71 - php artisan vendor:publish --tag=signal-config 72 - php artisan vendor:publish --tag=signal-migrations 73 - php artisan migrate 74 - ``` 75 - 76 - --- 77 - 78 - ## Quick Start 79 - 80 - ### 1. Create Your First Signal 81 - 82 - ```bash 83 - php artisan make:signal NewPostSignal 84 - ``` 85 - 86 - This creates `app/Signals/NewPostSignal.php`: 33 + ## Quick Example 87 34 88 35 ```php 89 - <?php 90 - 91 - namespace App\Signals; 92 - 93 36 use SocialDept\Signal\Events\SignalEvent; 94 37 use SocialDept\Signal\Signals\Signal; 95 38 ··· 117 60 } 118 61 ``` 119 62 120 - ### 2. Start Consuming Events 63 + Run `php artisan signal:consume` and start responding to every post on Bluesky in real-time. 121 64 122 - ```bash 123 - php artisan signal:consume 124 - ``` 125 - 126 - Your Signal will now respond to new posts on the AT Protocol network in real-time! 127 - 128 - --- 129 - 130 - ## Jetstream vs Firehose 131 - 132 - Signal supports two modes for consuming AT Protocol events. Choose based on your use case: 133 - 134 - ### Jetstream Mode (Default) 135 - 136 - **Best for**: Standard Bluesky collections, production efficiency, lower bandwidth 65 + ## Installation 137 66 138 67 ```bash 139 - php artisan signal:consume --mode=jetstream 68 + composer require socialdept/signal 69 + php artisan signal:install 140 70 ``` 141 71 142 - **Characteristics:** 143 - - ✅ Simplified JSON events (easy to work with) 144 - - ✅ Server-side collection filtering (efficient) 145 - - ✅ Lower bandwidth and processing overhead 146 - - ⚠️ Only standard `app.bsky.*` collections get create/update operations 147 - - ⚠️ Custom collections only receive delete operations 72 + That's it. [Read the installation docs →](docs/installation.md) 148 73 149 - **Jetstream URL options:** 150 - - US East: `wss://jetstream2.us-east.bsky.network` (default) 151 - - US West: `wss://jetstream1.us-west.bsky.network` 74 + ## Getting Started 152 75 153 - ### Firehose Mode 76 + Once installed, you're three steps away from consuming AT Protocol events: 154 77 155 - **Best for**: Custom collections, AppViews, comprehensive indexing 78 + ### 1. Create a Signal 156 79 157 80 ```bash 158 - php artisan signal:consume --mode=firehose 159 - ``` 160 - 161 - **Characteristics:** 162 - - ✅ **All operations** (create, update, delete) for **all collections** 163 - - ✅ Perfect for custom collections (e.g., `app.yourapp.*.collection`) 164 - - ✅ Full CBOR/CAR decoding with package `revolution/laravel-bluesky` 165 - - ⚠️ Client-side filtering only (higher bandwidth) 166 - - ⚠️ More processing overhead 167 - 168 - **When to use Firehose:** 169 - - Building an AT Protocol AppView 170 - - Working with custom collections 171 - - Need create/update events for non-standard collections 172 - - Building comprehensive indexes 173 - 174 - ### Configuration 175 - 176 - Set your preferred mode in `.env`: 177 - 178 - ```env 179 - # Use Jetstream (default) 180 - SIGNAL_MODE=jetstream 181 - 182 - # Or use Firehose for custom collections 183 - SIGNAL_MODE=firehose 81 + php artisan make:signal NewPostSignal 184 82 ``` 185 83 186 - ### Example: Custom Collections 187 - 188 - If you're tracking custom collections like `app.offprint.beta.publication`, you **must** use Firehose mode: 84 + ### 2. Define What to Listen For 189 85 190 86 ```php 191 - class PublicationSignal extends Signal 87 + public function collections(): ?array 192 88 { 193 - public function collections(): ?array 194 - { 195 - return ['app.offprint.beta.publication']; 196 - } 197 - 198 - public function handle(SignalEvent $event): void 199 - { 200 - // With Jetstream: Only sees deletes ❌ 201 - // With Firehose: Sees creates, updates, deletes ✅ 202 - } 89 + return ['app.bsky.feed.post']; 203 90 } 204 91 ``` 205 92 206 - --- 93 + ### 3. Start Consuming 207 94 208 - ## Creating Signals 95 + ```bash 96 + php artisan signal:consume 97 + ``` 209 98 210 - ### Basic Signal Structure 99 + Your Signal will now handle every matching event from the network. [Read the quickstart guide →](docs/quickstart.md) 211 100 212 - Every Signal extends the base `Signal` class and must implement: 101 + ## What can you build? 213 102 214 - ```php 215 - use SocialDept\Signal\Enums\SignalEventType; 216 - use SocialDept\Signal\Events\SignalEvent; 217 - use SocialDept\Signal\Signals\Signal; 103 + - **Custom feeds** - Curate content based on your own algorithms 104 + - **Moderation tools** - Detect and flag problematic content automatically 105 + - **Analytics platforms** - Track engagement, trends, and network growth 106 + - **Social integrations** - Mirror content to other platforms in real-time 107 + - **Notification systems** - Alert users about relevant activity 108 + - **AppViews** - Build custom AT Protocol applications with your own collections 218 109 219 - class MySignal extends Signal 220 - { 221 - // Required: Define which event types to listen for 222 - public function eventTypes(): array 223 - { 224 - return [SignalEventType::Commit]; 110 + ## Documentation 225 111 226 - // Or use strings: 227 - // return ['commit']; 228 - } 112 + **Getting Started** 113 + - [Installation](docs/installation.md) - Detailed setup instructions 114 + - [Quickstart Guide](docs/quickstart.md) - Build your first Signal 115 + - [Jetstream vs Firehose](docs/modes.md) - Choose the right mode 229 116 230 - // Required: Handle the event 231 - public function handle(SignalEvent $event): void 232 - { 233 - // Your logic here 234 - } 235 - } 236 - ``` 117 + **Building Signals** 118 + - [Creating Signals](docs/signals.md) - Complete Signal reference 119 + - [Filtering Events](docs/filtering.md) - Target specific collections and operations 120 + - [Queue Integration](docs/queues.md) - Process events asynchronously 237 121 238 - **Enums vs Strings**: Signal supports both typed enums and strings for better IDE support and type safety. Use whichever you prefer! 122 + **Advanced** 123 + - [Configuration](docs/configuration.md) - All config options explained 124 + - [Testing](docs/testing.md) - Test your Signals 125 + - [Examples](docs/examples.md) - Real-world use cases 239 126 240 - ### Event Types 241 - 242 - Three event types are available: 127 + ## Example Use Cases 243 128 244 - | Enum | String | Description | Use Cases | 245 - |-----------------------------|--------------|--------------------------------------------------|---------------------------------------| 246 - | `SignalEventType::Commit` | `'commit'` | Repository commits (posts, likes, follows, etc.) | Content creation, social interactions | 247 - | `SignalEventType::Identity` | `'identity'` | Identity changes (handle updates) | User profile tracking | 248 - | `SignalEventType::Account` | `'account'` | Account status changes | Account monitoring | 249 - 250 - ### Accessing Event Data 251 - 129 + ### Track User Growth 252 130 ```php 253 - use SocialDept\Signal\Enums\SignalCommitOperation; 254 - 255 - public function handle(SignalEvent $event): void 131 + public function collections(): ?array 256 132 { 257 - // Common properties 258 - $did = $event->did; // User's DID 259 - $kind = $event->kind; // Event type 260 - $timestamp = $event->timeUs; // Microsecond timestamp 261 - 262 - // Commit events 263 - if ($event->isCommit()) { 264 - $collection = $event->getCollection(); // e.g., 'app.bsky.feed.post' 265 - $operation = $event->getOperation(); // SignalCommitOperation enum 266 - $record = $event->getRecord(); // The actual record data 267 - $rkey = $event->commit->rkey; // Record key 268 - 269 - // Use enum for type-safe comparisons 270 - if ($operation === SignalCommitOperation::Create) { 271 - // Handle new records 272 - } 273 - 274 - // Or get string value 275 - $operationString = $operation->value; // 'create', 'update', or 'delete' 276 - } 277 - 278 - // Identity events 279 - if ($event->isIdentity()) { 280 - $handle = $event->identity->handle; 281 - } 282 - 283 - // Account events 284 - if ($event->isAccount()) { 285 - $active = $event->account->active; 286 - $status = $event->account->status; 287 - } 133 + return ['app.bsky.graph.follow']; 288 134 } 289 135 ``` 290 136 291 - --- 292 - 293 - ## Filtering Events 294 - 295 - ### Collection Filtering (with Wildcards!) 296 - 297 - Filter events by AT Protocol collection. 298 - 299 - **Important**: 300 - - **Jetstream mode**: Exact collection names are sent as URL parameters for server-side filtering. Wildcards work for client-side filtering only. 301 - - **Firehose mode**: All filtering is client-side. Wildcards work normally. 302 - 137 + ### Monitor Content Moderation 303 138 ```php 304 - // Exact match - only posts 305 - public function collections(): ?array 306 - { 307 - return ['app.bsky.feed.post']; 308 - } 309 - 310 - // Wildcard - all feed events 311 139 public function collections(): ?array 312 140 { 313 141 return ['app.bsky.feed.*']; 314 142 } 315 143 316 - // Multiple patterns 317 - public function collections(): ?array 318 - { 319 - return [ 320 - 'app.bsky.feed.post', 321 - 'app.bsky.feed.repost', 322 - 'app.bsky.graph.*', // All graph collections 323 - ]; 324 - } 325 - 326 - // No filter - all collections 327 - public function collections(): ?array 144 + public function shouldQueue(): bool 328 145 { 329 - return null; 146 + return true; // Process in background 330 147 } 331 148 ``` 332 149 333 - ### Common Collection Patterns 334 - 335 - | Pattern | Matches | 336 - |--------------------|-----------------------------| 337 - | `app.bsky.feed.*` | Posts, likes, reposts, etc. | 338 - | `app.bsky.graph.*` | Follows, blocks, mutes | 339 - | `app.bsky.actor.*` | Profile updates | 340 - | `app.bsky.*` | All Bluesky collections | 341 - 342 - ### Operation Filtering 343 - 344 - Filter events by operation type (only applies to `commit` events): 345 - 150 + ### Build Custom Collections (AppView) 346 151 ```php 347 - use SocialDept\Signal\Enums\SignalCommitOperation; 348 - 349 - // Only handle creates (using enum) 350 - public function operations(): ?array 152 + public function collections(): ?array 351 153 { 352 - return [SignalCommitOperation::Create]; 353 - } 354 - 355 - // Only handle creates and updates (using enums) 356 - public function operations(): ?array 357 - { 358 - return [ 359 - SignalCommitOperation::Create, 360 - SignalCommitOperation::Update, 361 - ]; 362 - } 363 - 364 - // Only handle deletes (using string) 365 - public function operations(): ?array 366 - { 367 - return ['delete']; 368 - } 369 - 370 - // No filter - all operations (default) 371 - public function operations(): ?array 372 - { 373 - return null; 154 + return ['app.yourapp.custom.collection']; 374 155 } 375 156 ``` 376 157 377 - **Available operations:** 378 - 379 - | Enum | String | Description | 380 - |---------------------------------|------------|---------------------------| 381 - | `SignalCommitOperation::Create` | `'create'` | New records created | 382 - | `SignalCommitOperation::Update` | `'update'` | Existing records modified | 383 - | `SignalCommitOperation::Delete` | `'delete'` | Records removed | 384 - 385 - **Example use cases:** 386 - ```php 387 - use SocialDept\Signal\Enums\SignalCommitOperation; 388 - 389 - // Signal that only handles new posts (not edits) 390 - class NewPostSignal extends Signal 391 - { 392 - public function collections(): ?array 393 - { 394 - return ['app.bsky.feed.post']; 395 - } 158 + [See more examples →](docs/examples.md) 396 159 397 - public function operations(): ?array 398 - { 399 - return [SignalCommitOperation::Create]; 400 - } 401 - } 160 + ## Key Features Explained 402 161 403 - // Signal that only handles content updates 404 - class ContentUpdateSignal extends Signal 405 - { 406 - public function collections(): ?array 407 - { 408 - return ['app.bsky.feed.post']; 409 - } 162 + ### Jetstream vs Firehose 410 163 411 - public function operations(): ?array 412 - { 413 - return [SignalCommitOperation::Update]; 414 - } 415 - } 164 + Signal supports two modes for consuming AT Protocol events: 416 165 417 - // Signal that handles deletions for cleanup 418 - class CleanupSignal extends Signal 419 - { 420 - public function collections(): ?array 421 - { 422 - return ['app.bsky.feed.*']; 423 - } 166 + - **Jetstream** (default) - Simplified JSON events with server-side filtering 167 + - **Firehose** - Raw CBOR/CAR format with client-side filtering 424 168 425 - public function operations(): ?array 426 - { 427 - return [SignalCommitOperation::Delete]; 428 - } 429 - } 430 - ``` 169 + [Learn more about modes →](docs/modes.md) 431 170 432 - ### DID Filtering 171 + ### Wildcard Filtering 433 172 434 - Filter events by specific users: 173 + Match multiple collections with patterns: 435 174 436 175 ```php 437 - public function dids(): ?array 176 + public function collections(): ?array 438 177 { 439 178 return [ 440 - 'did:plc:z72i7hdynmk6r22z27h6tvur', // Specific user 441 - 'did:plc:ragtjsm2j2vknwkz3zp4oxrd', // Another user 179 + 'app.bsky.feed.*', // All feed events 180 + 'app.bsky.graph.*', // All graph events 181 + 'app.yourapp.*', // All your custom collections 442 182 ]; 443 183 } 444 184 ``` 445 185 446 - ### Custom Filtering 186 + [Learn more about filtering →](docs/filtering.md) 447 187 448 - Add complex filtering logic: 188 + ### Queue Integration 449 189 450 - ```php 451 - public function shouldHandle(SignalEvent $event): bool 452 - { 453 - // Only handle posts with images 454 - if ($event->isCommit() && $event->commit->collection === 'app.bsky.feed.post') { 455 - $record = $event->getRecord(); 456 - return isset($record->embed); 457 - } 458 - 459 - return true; 460 - } 461 - ``` 462 - 463 - --- 464 - 465 - ## Queue Integration 466 - 467 - Process events asynchronously using Laravel queues: 190 + Process events asynchronously for better performance: 468 191 469 192 ```php 470 - class HeavyProcessingSignal extends Signal 193 + public function shouldQueue(): bool 471 194 { 472 - public function eventTypes(): array 473 - { 474 - return ['commit']; 475 - } 476 - 477 - // Enable queueing 478 - public function shouldQueue(): bool 479 - { 480 - return true; 481 - } 482 - 483 - // Optional: Customize queue 484 - public function queue(): string 485 - { 486 - return 'high-priority'; 487 - } 488 - 489 - // Optional: Customize connection 490 - public function queueConnection(): string 491 - { 492 - return 'redis'; 493 - } 494 - 495 - public function handle(SignalEvent $event): void 496 - { 497 - // This runs in a queue job 498 - $this->performExpensiveOperation($event); 499 - } 500 - 501 - // Handle failures 502 - public function failed(SignalEvent $event, \Throwable $exception): void 503 - { 504 - Log::error('Signal failed', [ 505 - 'event' => $event->toArray(), 506 - 'error' => $exception->getMessage(), 507 - ]); 508 - } 509 - } 510 - ``` 511 - 512 - --- 513 - 514 - ## Configuration 515 - 516 - Configuration is stored in `config/signal.php`: 517 - 518 - ### Consumer Mode 519 - 520 - Choose between Jetstream (JSON) or Firehose (CBOR) mode: 521 - 522 - ```php 523 - 'mode' => env('SIGNAL_MODE', 'jetstream'), 524 - ``` 525 - 526 - Options: 527 - - `jetstream` - JSON events, server-side filtering (default) 528 - - `firehose` - CBOR events, client-side filtering (required for custom collections) 529 - 530 - ### Jetstream Configuration 531 - 532 - ```php 533 - 'websocket_url' => env('SIGNAL_JETSTREAM_URL', 'wss://jetstream2.us-east.bsky.network'), 534 - ``` 535 - 536 - Available endpoints: 537 - - **US East**: `wss://jetstream2.us-east.bsky.network` (default) 538 - - **US West**: `wss://jetstream1.us-west.bsky.network` 539 - 540 - ### Firehose Configuration 541 - 542 - ```php 543 - 'firehose' => [ 544 - 'host' => env('SIGNAL_FIREHOSE_HOST', 'bsky.network'), 545 - ], 546 - ``` 547 - 548 - The raw firehose endpoint is: `wss://{host}/xrpc/com.atproto.sync.subscribeRepos` 549 - 550 - ### Cursor Storage 551 - 552 - Choose how to store cursor positions: 553 - 554 - ```php 555 - 'cursor_storage' => env('SIGNAL_CURSOR_STORAGE', 'database'), 556 - ``` 557 - 558 - | Driver | Best For | Configuration | 559 - |------------|-------------------------------|--------------------| 560 - | `database` | Production, multi-server | Default connection | 561 - | `redis` | High performance, distributed | Redis connection | 562 - | `file` | Development, single server | Storage path | 563 - 564 - ### Environment Variables 565 - 566 - Add to your `.env`: 567 - 568 - ```env 569 - # Consumer Mode 570 - SIGNAL_MODE=jetstream # or 'firehose' for custom collections 571 - 572 - # Jetstream Configuration 573 - SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network 574 - 575 - # Firehose Configuration (only needed if using firehose mode) 576 - SIGNAL_FIREHOSE_HOST=bsky.network 577 - 578 - # Optional Configuration 579 - SIGNAL_CURSOR_STORAGE=database 580 - SIGNAL_QUEUE_CONNECTION=redis 581 - SIGNAL_QUEUE=signal 582 - SIGNAL_BATCH_SIZE=100 583 - SIGNAL_RATE_LIMIT=1000 584 - ``` 585 - 586 - ### Auto-Discovery 587 - 588 - Signals are automatically discovered from `app/Signals`. Disable if needed: 589 - 590 - ```php 591 - 'auto_discovery' => [ 592 - 'enabled' => true, 593 - 'path' => app_path('Signals'), 594 - 'namespace' => 'App\\Signals', 595 - ], 596 - ``` 597 - 598 - Or manually register Signals: 599 - 600 - ```php 601 - 'signals' => [ 602 - \App\Signals\NewPostSignal::class, 603 - \App\Signals\NewFollowSignal::class, 604 - ], 605 - ``` 606 - 607 - --- 608 - 609 - ## Programmatic Usage 610 - 611 - You can start and stop the consumer programmatically using the `Signal` facade: 612 - 613 - ```php 614 - use SocialDept\Signal\Facades\Signal; 615 - 616 - // Start consuming events (uses mode from config) 617 - Signal::start(); 618 - 619 - // Start from a specific cursor 620 - Signal::start(cursor: 123456789); 621 - 622 - // Check which mode is active 623 - $mode = Signal::getMode(); // Returns 'jetstream' or 'firehose' 624 - 625 - // Stop consuming events 626 - Signal::stop(); 627 - ``` 628 - 629 - The facade automatically resolves the correct consumer (Jetstream or Firehose) based on your `config('signal.mode')` setting. This allows you to: 630 - 631 - - Switch between modes by changing configuration 632 - - Start consumers from application code (e.g., in a custom command) 633 - - Integrate Signal into existing application workflows 634 - 635 - ```php 636 - // Example: Start consumer based on environment 637 - if (app()->environment('production')) { 638 - config(['signal.mode' => 'jetstream']); // Use efficient Jetstream 639 - } else { 640 - config(['signal.mode' => 'firehose']); // Use comprehensive Firehose for testing 195 + return true; 641 196 } 642 - 643 - Signal::start(); 644 197 ``` 645 198 646 - --- 199 + [Learn more about queues →](docs/queues.md) 647 200 648 201 ## Available Commands 649 - 650 - ### `signal:install` 651 - Install the package (publish config, migrations, run migrations) 652 202 653 203 ```bash 204 + # Install Signal 654 205 php artisan signal:install 655 - ``` 656 - 657 - ### `signal:consume` 658 - Start consuming events from AT Protocol 659 - 660 - ```bash 661 - # Use default mode from config 662 - php artisan signal:consume 663 206 664 - # Override mode 665 - php artisan signal:consume --mode=jetstream 666 - php artisan signal:consume --mode=firehose 667 - 668 - # Start from specific cursor 669 - php artisan signal:consume --cursor=123456789 670 - 671 - # Start fresh (ignore stored cursor) 672 - php artisan signal:consume --fresh 207 + # Create a new Signal 208 + php artisan make:signal YourSignal 673 209 674 - # Combine options 675 - php artisan signal:consume --mode=firehose --fresh 676 - ``` 677 - 678 - ### `signal:list` 679 - List all registered Signals 680 - 681 - ```bash 210 + # List all registered Signals 682 211 php artisan signal:list 683 - ``` 684 - 685 - ### `signal:make` 686 - Create a new Signal class 687 - 688 - ```bash 689 - php artisan make:signal NewPostSignal 690 212 691 - # With options 692 - php artisan make:signal FollowSignal --type=commit --collection=app.bsky.graph.follow 693 - ``` 694 - 695 - ### `signal:test` 696 - Test a Signal with sample data 697 - 698 - ```bash 699 - php artisan signal:test NewPostSignal 700 - ``` 701 - 702 - --- 703 - 704 - ## Testing 705 - 706 - Signal includes a comprehensive test suite. Test your Signals: 707 - 708 - ### Unit Testing 709 - 710 - ```php 711 - use SocialDept\Signal\Events\CommitEvent; 712 - use SocialDept\Signal\Events\SignalEvent; 713 - 714 - class NewPostSignalTest extends TestCase 715 - { 716 - /** @test */ 717 - public function it_handles_new_posts() 718 - { 719 - $signal = new NewPostSignal(); 213 + # Start consuming events 214 + php artisan signal:consume 720 215 721 - $event = new SignalEvent( 722 - did: 'did:plc:test', 723 - timeUs: time() * 1000000, 724 - kind: 'commit', 725 - commit: new CommitEvent( 726 - rev: 'test', 727 - operation: 'create', 728 - collection: 'app.bsky.feed.post', 729 - rkey: 'test', 730 - record: (object) [ 731 - 'text' => 'Hello World!', 732 - 'createdAt' => now()->toIso8601String(), 733 - ], 734 - ), 735 - ); 736 - 737 - $signal->handle($event); 738 - 739 - // Assert your expected behavior 740 - } 741 - } 216 + # Test a Signal with sample data 217 + php artisan signal:test YourSignal 742 218 ``` 743 219 744 - ### Testing with Artisan 220 + ## Requirements 745 221 746 - ```bash 747 - php artisan signal:test NewPostSignal 748 - ``` 222 + - PHP 8.2+ 223 + - Laravel 11+ 224 + - WebSocket support (enabled by default) 749 225 750 - --- 751 - 752 - ## External Resources 226 + ## Resources 753 227 754 228 - [AT Protocol Documentation](https://atproto.com/) 229 + - [Bluesky API Docs](https://docs.bsky.app/) 755 230 - [Firehose Documentation](https://docs.bsky.app/docs/advanced-guides/firehose) 756 - - [Bluesky Lexicon](https://atproto.com/lexicons) 757 - 758 - --- 759 - 760 - ## Examples 761 - 762 - ### Monitor All Feed Activity 763 - 764 - ```php 765 - class FeedMonitorSignal extends Signal 766 - { 767 - public function eventTypes(): array 768 - { 769 - return ['commit']; 770 - } 771 - 772 - public function collections(): ?array 773 - { 774 - return ['app.bsky.feed.*']; 775 - } 776 - 777 - public function handle(SignalEvent $event): void 778 - { 779 - // Handles posts, likes, reposts, etc. 780 - Log::info('Feed activity', [ 781 - 'collection' => $event->getCollection(), 782 - 'operation' => $event->getOperation(), 783 - 'did' => $event->did, 784 - ]); 785 - } 786 - } 787 - ``` 788 - 789 - ### Track New Follows 790 - 791 - ```php 792 - class NewFollowSignal extends Signal 793 - { 794 - public function eventTypes(): array 795 - { 796 - return ['commit']; 797 - } 798 - 799 - public function collections(): ?array 800 - { 801 - return ['app.bsky.graph.follow']; 802 - } 803 - 804 - public function handle(SignalEvent $event): void 805 - { 806 - if ($event->commit->isCreate()) { 807 - $record = $event->getRecord(); 808 - 809 - // Store follow relationship 810 - Follow::create([ 811 - 'follower_did' => $event->did, 812 - 'following_did' => $record->subject, 813 - ]); 814 - } 815 - } 816 - } 817 - ``` 818 - 819 - ### Content Moderation 820 - 821 - ```php 822 - class ModerationSignal extends Signal 823 - { 824 - public function eventTypes(): array 825 - { 826 - return ['commit']; 827 - } 231 + - [Jetstream Documentation](https://github.com/bluesky-social/jetstream) 828 232 829 - public function collections(): ?array 830 - { 831 - return ['app.bsky.feed.post']; 832 - } 233 + ## Support & Contributing 833 234 834 - public function shouldQueue(): bool 835 - { 836 - return true; 837 - } 235 + Found a bug or have a feature request? [Open an issue](https://github.com/socialdept/signal/issues). 838 236 839 - public function handle(SignalEvent $event): void 840 - { 841 - $record = $event->getRecord(); 237 + Want to contribute? We'd love your help! Check out the [contribution guidelines](CONTRIBUTING.md). 842 238 843 - if ($this->containsProhibitedContent($record->text)) { 844 - $this->flagForModeration($event->did, $record); 845 - } 846 - } 847 - } 848 - ``` 239 + ## Credits 849 240 850 - --- 851 - 852 - ## Requirements 853 - 854 - - PHP 8.2 or higher 855 - - Laravel 11.0 or higher 856 - - WebSocket support (enabled by default in most environments) 857 - 858 - --- 241 + - [Miguel Batres](https://batres.co) - founder & lead maintainer 242 + - [All contributors](https://github.com/socialdept/signal/graphs/contributors) 859 243 860 244 ## License 861 245 862 - The MIT License (MIT). Please see [LICENSE](LICENSE) for more information. 246 + Signal is open-source software licensed under the [MIT license](LICENSE). 863 247 864 248 --- 865 249 866 - ## Support 867 - 868 - For issues, questions, or feature requests: 869 - - Read the [README.md](./README.md) before opening issues 870 - - Search through existing issues 871 - - Open new issue 872 - 873 - --- 874 - 875 - **Built for the AT Protocol ecosystem** • Made with ❤️ by Social Dept 250 + **Built for the Federation** • By Social Dept.
+687
docs/configuration.md
··· 1 + # Configuration 2 + 3 + Signal's configuration file provides complete control over how your application consumes AT Protocol events. 4 + 5 + ## Configuration File 6 + 7 + After installation, configuration lives in `config/signal.php`. 8 + 9 + ### Publishing Configuration 10 + 11 + Publish the config file manually if needed: 12 + 13 + ```bash 14 + php artisan vendor:publish --tag=signal-config 15 + ``` 16 + 17 + This creates `config/signal.php` with all available options. 18 + 19 + ## Environment Variables 20 + 21 + Most configuration can be set via `.env` for environment-specific values. 22 + 23 + ### Basic Configuration 24 + 25 + ```env 26 + # Consumer Mode (jetstream or firehose) 27 + SIGNAL_MODE=jetstream 28 + 29 + # Jetstream Configuration 30 + SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network 31 + 32 + # Firehose Configuration 33 + SIGNAL_FIREHOSE_HOST=bsky.network 34 + 35 + # Cursor Storage (database, redis, or file) 36 + SIGNAL_CURSOR_STORAGE=database 37 + 38 + # Queue Configuration 39 + SIGNAL_QUEUE_CONNECTION=redis 40 + SIGNAL_QUEUE=signal 41 + ``` 42 + 43 + ## Consumer Mode 44 + 45 + Choose between Jetstream and Firehose mode. 46 + 47 + ### Configuration 48 + 49 + ```php 50 + 'mode' => env('SIGNAL_MODE', 'jetstream'), 51 + ``` 52 + 53 + **Options:** 54 + - `jetstream` - JSON events, server-side filtering (default) 55 + - `firehose` - CBOR/CAR events, client-side filtering 56 + 57 + **Environment Variable:** 58 + ```env 59 + SIGNAL_MODE=jetstream 60 + ``` 61 + 62 + **When to use each:** 63 + - **Jetstream**: Standard Bluesky collections, production efficiency 64 + - **Firehose**: Custom collections, AppViews, comprehensive indexing 65 + 66 + [Learn more about modes →](modes.md) 67 + 68 + ## Jetstream Configuration 69 + 70 + Configuration specific to Jetstream mode. 71 + 72 + ### WebSocket URL 73 + 74 + ```php 75 + 'jetstream' => [ 76 + 'websocket_url' => env( 77 + 'SIGNAL_JETSTREAM_URL', 78 + 'wss://jetstream2.us-east.bsky.network' 79 + ), 80 + ], 81 + ``` 82 + 83 + **Available Endpoints:** 84 + 85 + **US East (Default):** 86 + ```env 87 + SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network 88 + ``` 89 + 90 + **US West:** 91 + ```env 92 + SIGNAL_JETSTREAM_URL=wss://jetstream1.us-west.bsky.network 93 + ``` 94 + 95 + Choose the endpoint closest to your server for best latency. 96 + 97 + ## Firehose Configuration 98 + 99 + Configuration specific to Firehose mode. 100 + 101 + ### Host 102 + 103 + ```php 104 + 'firehose' => [ 105 + 'host' => env('SIGNAL_FIREHOSE_HOST', 'bsky.network'), 106 + ], 107 + ``` 108 + 109 + The WebSocket URL is constructed as: 110 + ``` 111 + wss://{host}/xrpc/com.atproto.sync.subscribeRepos 112 + ``` 113 + 114 + **Environment Variable:** 115 + ```env 116 + SIGNAL_FIREHOSE_HOST=bsky.network 117 + ``` 118 + 119 + **Default Host:** `bsky.network` 120 + 121 + **Custom Hosts:** If you're running your own AT Protocol PDS, specify it here: 122 + ```env 123 + SIGNAL_FIREHOSE_HOST=my-pds.example.com 124 + ``` 125 + 126 + ## Cursor Storage 127 + 128 + Configure how Signal stores cursor positions for resuming after disconnections. 129 + 130 + ### Storage Driver 131 + 132 + ```php 133 + 'cursor_storage' => env('SIGNAL_CURSOR_STORAGE', 'database'), 134 + ``` 135 + 136 + **Available Drivers:** 137 + - `database` - Store in database (recommended for production) 138 + - `redis` - Store in Redis (high performance) 139 + - `file` - Store in filesystem (development only) 140 + 141 + **Environment Variable:** 142 + ```env 143 + SIGNAL_CURSOR_STORAGE=database 144 + ``` 145 + 146 + ### Database Driver 147 + 148 + Uses Laravel's default database connection. 149 + 150 + **Configuration:** 151 + ```php 152 + 'cursor_storage' => 'database', 153 + ``` 154 + 155 + **Requires:** 156 + - Migration published and run 157 + - Database connection configured 158 + 159 + **Table:** `signal_cursors` 160 + 161 + ### Redis Driver 162 + 163 + Stores cursors in Redis for high performance. 164 + 165 + **Configuration:** 166 + ```php 167 + 'cursor_storage' => 'redis', 168 + 169 + 'redis' => [ 170 + 'connection' => env('SIGNAL_REDIS_CONNECTION', 'default'), 171 + 'key_prefix' => env('SIGNAL_REDIS_PREFIX', 'signal:cursor:'), 172 + ], 173 + ``` 174 + 175 + **Environment Variables:** 176 + ```env 177 + SIGNAL_CURSOR_STORAGE=redis 178 + SIGNAL_REDIS_CONNECTION=default 179 + SIGNAL_REDIS_PREFIX=signal:cursor: 180 + ``` 181 + 182 + **Requires:** 183 + - Redis connection configured in `config/database.php` 184 + - Redis server running 185 + 186 + ### File Driver 187 + 188 + Stores cursors in the filesystem (development only). 189 + 190 + **Configuration:** 191 + ```php 192 + 'cursor_storage' => 'file', 193 + 194 + 'file' => [ 195 + 'path' => env('SIGNAL_FILE_PATH', storage_path('app/signal')), 196 + ], 197 + ``` 198 + 199 + **Environment Variables:** 200 + ```env 201 + SIGNAL_CURSOR_STORAGE=file 202 + SIGNAL_FILE_PATH=/path/to/storage/signal 203 + ``` 204 + 205 + **Not recommended for production:** 206 + - Single server only 207 + - No clustering support 208 + - Filesystem I/O overhead 209 + 210 + ## Queue Configuration 211 + 212 + Configure how Signal dispatches queued jobs. 213 + 214 + ### Queue Connection 215 + 216 + ```php 217 + 'queue' => [ 218 + 'connection' => env('SIGNAL_QUEUE_CONNECTION', null), 219 + 'queue' => env('SIGNAL_QUEUE', 'default'), 220 + ], 221 + ``` 222 + 223 + **Environment Variables:** 224 + ```env 225 + # Queue connection (redis, database, sqs, etc.) 226 + SIGNAL_QUEUE_CONNECTION=redis 227 + 228 + # Queue name 229 + SIGNAL_QUEUE=signal 230 + ``` 231 + 232 + **Defaults:** 233 + - `connection`: Uses Laravel's default queue connection 234 + - `queue`: Uses Laravel's default queue name 235 + 236 + ### Per-Signal Configuration 237 + 238 + Signals can override queue configuration: 239 + 240 + ```php 241 + public function shouldQueue(): bool 242 + { 243 + return true; 244 + } 245 + 246 + public function queueConnection(): string 247 + { 248 + return 'redis'; // Override connection 249 + } 250 + 251 + public function queue(): string 252 + { 253 + return 'high-priority'; // Override queue name 254 + } 255 + ``` 256 + 257 + [Learn more about queue integration →](queues.md) 258 + 259 + ## Auto-Discovery 260 + 261 + Configure automatic Signal discovery. 262 + 263 + ### Enable/Disable 264 + 265 + ```php 266 + 'auto_discovery' => [ 267 + 'enabled' => true, 268 + 'path' => app_path('Signals'), 269 + 'namespace' => 'App\\Signals', 270 + ], 271 + ``` 272 + 273 + **Options:** 274 + - `enabled`: Enable/disable auto-discovery (default: `true`) 275 + - `path`: Directory to scan for Signals (default: `app/Signals`) 276 + - `namespace`: Namespace for discovered Signals (default: `App\Signals`) 277 + 278 + ### Disable Auto-Discovery 279 + 280 + Manually register Signals instead: 281 + 282 + ```php 283 + 'auto_discovery' => [ 284 + 'enabled' => false, 285 + ], 286 + 287 + 'signals' => [ 288 + \App\Signals\NewPostSignal::class, 289 + \App\Signals\NewFollowSignal::class, 290 + ], 291 + ``` 292 + 293 + ### Custom Discovery Path 294 + 295 + Organize Signals in a custom directory: 296 + 297 + ```php 298 + 'auto_discovery' => [ 299 + 'enabled' => true, 300 + 'path' => app_path('Domain/Signals'), 301 + 'namespace' => 'App\\Domain\\Signals', 302 + ], 303 + ``` 304 + 305 + ## Manual Signal Registration 306 + 307 + Register Signals explicitly. 308 + 309 + ### Configuration 310 + 311 + ```php 312 + 'signals' => [ 313 + \App\Signals\NewPostSignal::class, 314 + \App\Signals\NewFollowSignal::class, 315 + \App\Signals\ProfileUpdateSignal::class, 316 + ], 317 + ``` 318 + 319 + **When to use:** 320 + - Auto-discovery disabled 321 + - Signals outside standard directory 322 + - Fine-grained control over which Signals run 323 + 324 + ## Logging 325 + 326 + Signal uses Laravel's logging system. 327 + 328 + ### Configure Logging 329 + 330 + Standard Laravel log configuration applies: 331 + 332 + ```php 333 + // config/logging.php 334 + 'channels' => [ 335 + 'signal' => [ 336 + 'driver' => 'daily', 337 + 'path' => storage_path('logs/signal.log'), 338 + 'level' => env('SIGNAL_LOG_LEVEL', 'debug'), 339 + 'days' => 14, 340 + ], 341 + ], 342 + ``` 343 + 344 + Use in Signals: 345 + 346 + ```php 347 + use Illuminate\Support\Facades\Log; 348 + 349 + public function handle(SignalEvent $event): void 350 + { 351 + Log::channel('signal')->info('Processing event', [ 352 + 'did' => $event->did, 353 + ]); 354 + } 355 + ``` 356 + 357 + ## Complete Configuration Reference 358 + 359 + Here's the full `config/signal.php` with all options: 360 + 361 + ```php 362 + <?php 363 + 364 + return [ 365 + 366 + /* 367 + |-------------------------------------------------------------------------- 368 + | Consumer Mode 369 + |-------------------------------------------------------------------------- 370 + | 371 + | Choose between 'jetstream' (JSON events) or 'firehose' (CBOR/CAR events). 372 + | Jetstream is more efficient for standard Bluesky collections. 373 + | Firehose is required for custom collections. 374 + | 375 + | Options: 'jetstream', 'firehose' 376 + | 377 + */ 378 + 379 + 'mode' => env('SIGNAL_MODE', 'jetstream'), 380 + 381 + /* 382 + |-------------------------------------------------------------------------- 383 + | Jetstream Configuration 384 + |-------------------------------------------------------------------------- 385 + | 386 + | Configuration for Jetstream mode (JSON events). 387 + | 388 + */ 389 + 390 + 'jetstream' => [ 391 + 'websocket_url' => env( 392 + 'SIGNAL_JETSTREAM_URL', 393 + 'wss://jetstream2.us-east.bsky.network' 394 + ), 395 + ], 396 + 397 + /* 398 + |-------------------------------------------------------------------------- 399 + | Firehose Configuration 400 + |-------------------------------------------------------------------------- 401 + | 402 + | Configuration for Firehose mode (CBOR/CAR events). 403 + | 404 + */ 405 + 406 + 'firehose' => [ 407 + 'host' => env('SIGNAL_FIREHOSE_HOST', 'bsky.network'), 408 + ], 409 + 410 + /* 411 + |-------------------------------------------------------------------------- 412 + | Cursor Storage 413 + |-------------------------------------------------------------------------- 414 + | 415 + | Configure how Signal stores cursor positions for resuming after 416 + | disconnections. Options: 'database', 'redis', 'file' 417 + | 418 + */ 419 + 420 + 'cursor_storage' => env('SIGNAL_CURSOR_STORAGE', 'database'), 421 + 422 + /* 423 + |-------------------------------------------------------------------------- 424 + | Redis Configuration 425 + |-------------------------------------------------------------------------- 426 + | 427 + | Configuration for Redis cursor storage. 428 + | 429 + */ 430 + 431 + 'redis' => [ 432 + 'connection' => env('SIGNAL_REDIS_CONNECTION', 'default'), 433 + 'key_prefix' => env('SIGNAL_REDIS_PREFIX', 'signal:cursor:'), 434 + ], 435 + 436 + /* 437 + |-------------------------------------------------------------------------- 438 + | File Configuration 439 + |-------------------------------------------------------------------------- 440 + | 441 + | Configuration for file-based cursor storage. 442 + | 443 + */ 444 + 445 + 'file' => [ 446 + 'path' => env('SIGNAL_FILE_PATH', storage_path('app/signal')), 447 + ], 448 + 449 + /* 450 + |-------------------------------------------------------------------------- 451 + | Queue Configuration 452 + |-------------------------------------------------------------------------- 453 + | 454 + | Configure queue connection and name for processing events asynchronously. 455 + | 456 + */ 457 + 458 + 'queue' => [ 459 + 'connection' => env('SIGNAL_QUEUE_CONNECTION', null), 460 + 'queue' => env('SIGNAL_QUEUE', 'default'), 461 + ], 462 + 463 + /* 464 + |-------------------------------------------------------------------------- 465 + | Auto-Discovery 466 + |-------------------------------------------------------------------------- 467 + | 468 + | Automatically discover and register Signals from the specified directory. 469 + | 470 + */ 471 + 472 + 'auto_discovery' => [ 473 + 'enabled' => true, 474 + 'path' => app_path('Signals'), 475 + 'namespace' => 'App\\Signals', 476 + ], 477 + 478 + /* 479 + |-------------------------------------------------------------------------- 480 + | Manual Signal Registration 481 + |-------------------------------------------------------------------------- 482 + | 483 + | Manually register Signals if auto-discovery is disabled. 484 + | 485 + */ 486 + 487 + 'signals' => [ 488 + // \App\Signals\NewPostSignal::class, 489 + ], 490 + 491 + ]; 492 + ``` 493 + 494 + ## Environment-Specific Configuration 495 + 496 + ### Development 497 + 498 + ```env 499 + SIGNAL_MODE=firehose 500 + SIGNAL_CURSOR_STORAGE=file 501 + SIGNAL_QUEUE_CONNECTION=sync 502 + ``` 503 + 504 + **Why:** 505 + - Firehose mode sees all events (comprehensive testing) 506 + - File storage is simple and adequate 507 + - Sync queue processes immediately (easier debugging) 508 + 509 + ### Staging 510 + 511 + ```env 512 + SIGNAL_MODE=jetstream 513 + SIGNAL_CURSOR_STORAGE=redis 514 + SIGNAL_QUEUE_CONNECTION=redis 515 + SIGNAL_QUEUE=signal-staging 516 + ``` 517 + 518 + **Why:** 519 + - Jetstream mode matches production 520 + - Redis for performance testing 521 + - Separate queue for staging isolation 522 + 523 + ### Production 524 + 525 + ```env 526 + SIGNAL_MODE=jetstream 527 + SIGNAL_CURSOR_STORAGE=database 528 + SIGNAL_QUEUE_CONNECTION=redis 529 + SIGNAL_QUEUE=signal 530 + ``` 531 + 532 + **Why:** 533 + - Jetstream mode for efficiency 534 + - Database storage for reliability 535 + - Redis queues for performance 536 + 537 + ## Runtime Configuration 538 + 539 + Change configuration at runtime: 540 + 541 + ```php 542 + use SocialDept\Signal\Facades\Signal; 543 + 544 + // Override mode 545 + config(['signal.mode' => 'firehose']); 546 + 547 + // Override cursor storage 548 + config(['signal.cursor_storage' => 'redis']); 549 + 550 + // Start consumer with new config 551 + Signal::start(); 552 + ``` 553 + 554 + ## Validation 555 + 556 + Signal validates configuration on startup: 557 + 558 + ```bash 559 + php artisan signal:consume 560 + ``` 561 + 562 + **Checks:** 563 + - Mode is valid (`jetstream` or `firehose`) 564 + - Cursor storage driver exists 565 + - Required endpoints are configured 566 + - Queue configuration is valid 567 + 568 + **Validation errors prevent consumer from starting.** 569 + 570 + ## Configuration Helpers 571 + 572 + ### Check Current Mode 573 + 574 + ```php 575 + $mode = config('signal.mode'); // 'jetstream' or 'firehose' 576 + ``` 577 + 578 + Or via Facade: 579 + 580 + ```php 581 + use SocialDept\Signal\Facades\Signal; 582 + 583 + $mode = Signal::getMode(); 584 + ``` 585 + 586 + ### Check Cursor Storage 587 + 588 + ```php 589 + $storage = config('signal.cursor_storage'); // 'database', 'redis', or 'file' 590 + ``` 591 + 592 + ### Check Queue Configuration 593 + 594 + ```php 595 + $connection = config('signal.queue.connection'); 596 + $queue = config('signal.queue.queue'); 597 + ``` 598 + 599 + ## Best Practices 600 + 601 + ### Use Environment Variables 602 + 603 + Don't hardcode values in config file: 604 + 605 + ```php 606 + // Good 607 + 'mode' => env('SIGNAL_MODE', 'jetstream'), 608 + 609 + // Bad 610 + 'mode' => 'jetstream', 611 + ``` 612 + 613 + ### Separate Staging and Production 614 + 615 + Use different queues and storage: 616 + 617 + ```env 618 + # .env.staging 619 + SIGNAL_QUEUE=signal-staging 620 + 621 + # .env.production 622 + SIGNAL_QUEUE=signal-production 623 + ``` 624 + 625 + ### Document Custom Configuration 626 + 627 + If you change defaults, document why: 628 + 629 + ```php 630 + // We use Firehose mode because we have custom collections 631 + 'mode' => env('SIGNAL_MODE', 'firehose'), 632 + ``` 633 + 634 + ### Version Control 635 + 636 + Commit `config/signal.php` but not `.env`: 637 + 638 + ```gitignore 639 + # .gitignore 640 + .env 641 + .env.* 642 + 643 + # Commit 644 + config/signal.php 645 + ``` 646 + 647 + ## Troubleshooting 648 + 649 + ### Configuration Not Loading 650 + 651 + Clear config cache: 652 + 653 + ```bash 654 + php artisan config:clear 655 + php artisan config:cache 656 + ``` 657 + 658 + ### Environment Variables Not Working 659 + 660 + Check `.env` file exists and is readable: 661 + 662 + ```bash 663 + ls -la .env 664 + ``` 665 + 666 + Restart services after changing `.env`: 667 + 668 + ```bash 669 + # If using Supervisor 670 + sudo supervisorctl restart signal-consumer:* 671 + ``` 672 + 673 + ### Invalid Configuration 674 + 675 + Run consumer to see validation errors: 676 + 677 + ```bash 678 + php artisan signal:consume 679 + ``` 680 + 681 + Signal will display specific errors about misconfiguration. 682 + 683 + ## Next Steps 684 + 685 + - **[Learn about testing →](testing.md)** - Test your configuration 686 + - **[See real-world examples →](examples.md)** - Learn from production configurations 687 + - **[Review queue integration →](queues.md)** - Configure queues optimally
+795
docs/examples.md
··· 1 + # Real-World Examples 2 + 3 + Learn from production-ready Signal examples covering common use cases. 4 + 5 + ## Social Media Analytics 6 + 7 + Track engagement metrics across Bluesky. 8 + 9 + ```php 10 + <?php 11 + 12 + namespace App\Signals; 13 + 14 + use App\Models\EngagementMetric; 15 + use SocialDept\Signal\Enums\SignalCommitOperation; 16 + use SocialDept\Signal\Events\SignalEvent; 17 + use SocialDept\Signal\Signals\Signal; 18 + use Illuminate\Support\Facades\DB; 19 + 20 + class EngagementTrackerSignal extends Signal 21 + { 22 + public function eventTypes(): array 23 + { 24 + return ['commit']; 25 + } 26 + 27 + public function collections(): ?array 28 + { 29 + return [ 30 + 'app.bsky.feed.post', 31 + 'app.bsky.feed.like', 32 + 'app.bsky.feed.repost', 33 + 'app.bsky.graph.follow', 34 + ]; 35 + } 36 + 37 + public function operations(): ?array 38 + { 39 + return [SignalCommitOperation::Create]; 40 + } 41 + 42 + public function shouldQueue(): bool 43 + { 44 + return true; 45 + } 46 + 47 + public function handle(SignalEvent $event): void 48 + { 49 + $collection = $event->getCollection(); 50 + $timestamp = $event->getTimestamp(); 51 + 52 + // Increment counter for this hour 53 + DB::table('engagement_metrics') 54 + ->updateOrInsert( 55 + [ 56 + 'collection' => $collection, 57 + 'hour' => $timestamp->startOfHour(), 58 + ], 59 + [ 60 + 'count' => DB::raw('count + 1'), 61 + 'updated_at' => now(), 62 + ] 63 + ); 64 + } 65 + } 66 + ``` 67 + 68 + **Use case:** Build analytics dashboards showing posts/hour, likes/hour, follows/hour. 69 + 70 + ## Content Moderation 71 + 72 + Automatically flag problematic content. 73 + 74 + ```php 75 + <?php 76 + 77 + namespace App\Signals; 78 + 79 + use App\Models\FlaggedPost; 80 + use App\Services\ModerationService; 81 + use SocialDept\Signal\Enums\SignalCommitOperation; 82 + use SocialDept\Signal\Events\SignalEvent; 83 + use SocialDept\Signal\Signals\Signal; 84 + 85 + class ModerationSignal extends Signal 86 + { 87 + public function __construct( 88 + private ModerationService $moderation 89 + ) {} 90 + 91 + public function eventTypes(): array 92 + { 93 + return ['commit']; 94 + } 95 + 96 + public function collections(): ?array 97 + { 98 + return ['app.bsky.feed.post']; 99 + } 100 + 101 + public function operations(): ?array 102 + { 103 + return [SignalCommitOperation::Create]; 104 + } 105 + 106 + public function shouldQueue(): bool 107 + { 108 + return true; 109 + } 110 + 111 + public function queue(): string 112 + { 113 + return 'moderation'; 114 + } 115 + 116 + public function handle(SignalEvent $event): void 117 + { 118 + $record = $event->getRecord(); 119 + 120 + if (!isset($record->text)) { 121 + return; 122 + } 123 + 124 + $result = $this->moderation->analyze($record->text); 125 + 126 + if ($result->needsReview) { 127 + FlaggedPost::create([ 128 + 'did' => $event->did, 129 + 'rkey' => $event->commit->rkey, 130 + 'text' => $record->text, 131 + 'reason' => $result->reason, 132 + 'confidence' => $result->confidence, 133 + 'flagged_at' => now(), 134 + ]); 135 + } 136 + } 137 + 138 + public function failed(SignalEvent $event, \Throwable $exception): void 139 + { 140 + Log::error('Moderation signal failed', [ 141 + 'did' => $event->did, 142 + 'error' => $exception->getMessage(), 143 + ]); 144 + } 145 + } 146 + ``` 147 + 148 + **Use case:** Automated content moderation with human review queue. 149 + 150 + ## User Activity Feed 151 + 152 + Build a personalized activity feed. 153 + 154 + ```php 155 + <?php 156 + 157 + namespace App\Signals; 158 + 159 + use App\Models\Activity; 160 + use App\Models\User; 161 + use SocialDept\Signal\Enums\SignalCommitOperation; 162 + use SocialDept\Signal\Events\SignalEvent; 163 + use SocialDept\Signal\Signals\Signal; 164 + 165 + class ActivityFeedSignal extends Signal 166 + { 167 + public function eventTypes(): array 168 + { 169 + return ['commit']; 170 + } 171 + 172 + public function collections(): ?array 173 + { 174 + return [ 175 + 'app.bsky.feed.post', 176 + 'app.bsky.feed.like', 177 + 'app.bsky.feed.repost', 178 + ]; 179 + } 180 + 181 + public function operations(): ?array 182 + { 183 + return [SignalCommitOperation::Create]; 184 + } 185 + 186 + public function shouldQueue(): bool 187 + { 188 + return true; 189 + } 190 + 191 + public function handle(SignalEvent $event): void 192 + { 193 + // Check if we're tracking this user 194 + $user = User::where('did', $event->did)->first(); 195 + 196 + if (!$user) { 197 + return; 198 + } 199 + 200 + // Check if any followers want to see this 201 + $followerIds = $user->followers()->pluck('id'); 202 + 203 + if ($followerIds->isEmpty()) { 204 + return; 205 + } 206 + 207 + $collection = $event->getCollection(); 208 + 209 + // Create activity for each follower's feed 210 + foreach ($followerIds as $followerId) { 211 + Activity::create([ 212 + 'user_id' => $followerId, 213 + 'actor_did' => $event->did, 214 + 'type' => $this->getActivityType($collection), 215 + 'data' => $event->toArray(), 216 + 'created_at' => $event->getTimestamp(), 217 + ]); 218 + } 219 + } 220 + 221 + private function getActivityType(string $collection): string 222 + { 223 + return match ($collection) { 224 + 'app.bsky.feed.post' => 'post', 225 + 'app.bsky.feed.like' => 'like', 226 + 'app.bsky.feed.repost' => 'repost', 227 + default => 'unknown', 228 + }; 229 + } 230 + } 231 + ``` 232 + 233 + **Use case:** Show users activity from people they follow. 234 + 235 + ## Real-Time Notifications 236 + 237 + Send notifications for mentions and interactions. 238 + 239 + ```php 240 + <?php 241 + 242 + namespace App\Signals; 243 + 244 + use App\Models\User; 245 + use App\Notifications\MentionedInPost; 246 + use SocialDept\Signal\Enums\SignalCommitOperation; 247 + use SocialDept\Signal\Events\SignalEvent; 248 + use SocialDept\Signal\Signals\Signal; 249 + 250 + class MentionNotificationSignal extends Signal 251 + { 252 + public function eventTypes(): array 253 + { 254 + return ['commit']; 255 + } 256 + 257 + public function collections(): ?array 258 + { 259 + return ['app.bsky.feed.post']; 260 + } 261 + 262 + public function operations(): ?array 263 + { 264 + return [SignalCommitOperation::Create]; 265 + } 266 + 267 + public function shouldQueue(): bool 268 + { 269 + return true; 270 + } 271 + 272 + public function handle(SignalEvent $event): void 273 + { 274 + $record = $event->getRecord(); 275 + 276 + if (!isset($record->facets)) { 277 + return; 278 + } 279 + 280 + // Extract mentions from facets 281 + $mentions = collect($record->facets) 282 + ->filter(fn($facet) => isset($facet->features)) 283 + ->flatMap(fn($facet) => $facet->features) 284 + ->filter(fn($feature) => $feature->{'$type'} === 'app.bsky.richtext.facet#mention') 285 + ->pluck('did') 286 + ->unique(); 287 + 288 + foreach ($mentions as $mentionedDid) { 289 + $user = User::where('did', $mentionedDid)->first(); 290 + 291 + if ($user) { 292 + $user->notify(new MentionedInPost( 293 + authorDid: $event->did, 294 + text: $record->text ?? '', 295 + uri: "at://{$event->did}/app.bsky.feed.post/{$event->commit->rkey}" 296 + )); 297 + } 298 + } 299 + } 300 + } 301 + ``` 302 + 303 + **Use case:** Real-time notifications when users are mentioned. 304 + 305 + ## Follow Tracker 306 + 307 + Track follow relationships and send notifications. 308 + 309 + ```php 310 + <?php 311 + 312 + namespace App\Signals; 313 + 314 + use App\Models\Follow; 315 + use App\Models\User; 316 + use App\Notifications\NewFollower; 317 + use SocialDept\Signal\Enums\SignalCommitOperation; 318 + use SocialDept\Signal\Events\SignalEvent; 319 + use SocialDept\Signal\Signals\Signal; 320 + 321 + class FollowTrackerSignal extends Signal 322 + { 323 + public function eventTypes(): array 324 + { 325 + return ['commit']; 326 + } 327 + 328 + public function collections(): ?array 329 + { 330 + return ['app.bsky.graph.follow']; 331 + } 332 + 333 + public function operations(): ?array 334 + { 335 + return [ 336 + SignalCommitOperation::Create, 337 + SignalCommitOperation::Delete, 338 + ]; 339 + } 340 + 341 + public function shouldQueue(): bool 342 + { 343 + return true; 344 + } 345 + 346 + public function handle(SignalEvent $event): void 347 + { 348 + $record = $event->getRecord(); 349 + $operation = $event->getOperation(); 350 + 351 + if ($operation === SignalCommitOperation::Create) { 352 + $this->handleNewFollow($event, $record); 353 + } else { 354 + $this->handleUnfollow($event); 355 + } 356 + } 357 + 358 + private function handleNewFollow(SignalEvent $event, object $record): void 359 + { 360 + Follow::create([ 361 + 'follower_did' => $event->did, 362 + 'following_did' => $record->subject, 363 + 'created_at' => $record->createdAt ?? now(), 364 + ]); 365 + 366 + // Notify the followed user 367 + $followedUser = User::where('did', $record->subject)->first(); 368 + 369 + if ($followedUser) { 370 + $followedUser->notify(new NewFollower($event->did)); 371 + } 372 + } 373 + 374 + private function handleUnfollow(SignalEvent $event): void 375 + { 376 + Follow::where('follower_did', $event->did) 377 + ->where('rkey', $event->commit->rkey) 378 + ->delete(); 379 + } 380 + } 381 + ``` 382 + 383 + **Use case:** Track follows and notify users of new followers. 384 + 385 + ## Search Indexer 386 + 387 + Index posts for full-text search. 388 + 389 + ```php 390 + <?php 391 + 392 + namespace App\Signals; 393 + 394 + use App\Models\Post; 395 + use Laravel\Scout\Searchable; 396 + use SocialDept\Signal\Enums\SignalCommitOperation; 397 + use SocialDept\Signal\Events\SignalEvent; 398 + use SocialDept\Signal\Signals\Signal; 399 + 400 + class SearchIndexerSignal extends Signal 401 + { 402 + public function eventTypes(): array 403 + { 404 + return ['commit']; 405 + } 406 + 407 + public function collections(): ?array 408 + { 409 + return ['app.bsky.feed.post']; 410 + } 411 + 412 + public function shouldQueue(): bool 413 + { 414 + return true; 415 + } 416 + 417 + public function queue(): string 418 + { 419 + return 'indexing'; 420 + } 421 + 422 + public function handle(SignalEvent $event): void 423 + { 424 + $operation = $event->getOperation(); 425 + 426 + match ($operation) { 427 + SignalCommitOperation::Create, 428 + SignalCommitOperation::Update => $this->indexPost($event), 429 + SignalCommitOperation::Delete => $this->deletePost($event), 430 + }; 431 + } 432 + 433 + private function indexPost(SignalEvent $event): void 434 + { 435 + $record = $event->getRecord(); 436 + 437 + $post = Post::updateOrCreate( 438 + [ 439 + 'did' => $event->did, 440 + 'rkey' => $event->commit->rkey, 441 + ], 442 + [ 443 + 'text' => $record->text ?? '', 444 + 'created_at' => $record->createdAt ?? now(), 445 + 'indexed_at' => now(), 446 + ] 447 + ); 448 + 449 + // Scout automatically indexes 450 + $post->searchable(); 451 + } 452 + 453 + private function deletePost(SignalEvent $event): void 454 + { 455 + $post = Post::where('did', $event->did) 456 + ->where('rkey', $event->commit->rkey) 457 + ->first(); 458 + 459 + if ($post) { 460 + $post->unsearchable(); 461 + $post->delete(); 462 + } 463 + } 464 + } 465 + ``` 466 + 467 + **Use case:** Full-text search across all Bluesky posts. 468 + 469 + ## Trend Detection 470 + 471 + Identify trending topics and hashtags. 472 + 473 + ```php 474 + <?php 475 + 476 + namespace App\Signals; 477 + 478 + use App\Models\TrendingTopic; 479 + use Illuminate\Support\Facades\Cache; 480 + use SocialDept\Signal\Enums\SignalCommitOperation; 481 + use SocialDept\Signal\Events\SignalEvent; 482 + use SocialDept\Signal\Signals\Signal; 483 + 484 + class TrendDetectionSignal extends Signal 485 + { 486 + public function eventTypes(): array 487 + { 488 + return ['commit']; 489 + } 490 + 491 + public function collections(): ?array 492 + { 493 + return ['app.bsky.feed.post']; 494 + } 495 + 496 + public function operations(): ?array 497 + { 498 + return [SignalCommitOperation::Create]; 499 + } 500 + 501 + public function shouldQueue(): bool 502 + { 503 + return true; 504 + } 505 + 506 + public function handle(SignalEvent $event): void 507 + { 508 + $record = $event->getRecord(); 509 + 510 + if (!isset($record->text)) { 511 + return; 512 + } 513 + 514 + // Extract hashtags 515 + preg_match_all('/#(\w+)/', $record->text, $matches); 516 + 517 + foreach ($matches[1] as $hashtag) { 518 + $this->incrementHashtag($hashtag); 519 + } 520 + } 521 + 522 + private function incrementHashtag(string $hashtag): void 523 + { 524 + $key = "trending:hashtag:{$hashtag}"; 525 + 526 + // Increment counter (expires after 1 hour) 527 + $count = Cache::increment($key, 1); 528 + 529 + if (!Cache::has($key)) { 530 + Cache::put($key, 1, now()->addHour()); 531 + } 532 + 533 + // Update trending topics if threshold reached 534 + if ($count > 100) { 535 + TrendingTopic::updateOrCreate( 536 + ['hashtag' => $hashtag], 537 + ['count' => $count, 'updated_at' => now()] 538 + ); 539 + } 540 + } 541 + } 542 + ``` 543 + 544 + **Use case:** Identify trending hashtags and topics in real-time. 545 + 546 + ## Custom AppView 547 + 548 + Index custom collections for your AppView. 549 + 550 + ```php 551 + <?php 552 + 553 + namespace App\Signals; 554 + 555 + use App\Models\Publication; 556 + use SocialDept\Signal\Enums\SignalCommitOperation; 557 + use SocialDept\Signal\Events\SignalEvent; 558 + use SocialDept\Signal\Signals\Signal; 559 + 560 + class PublicationIndexerSignal extends Signal 561 + { 562 + public function eventTypes(): array 563 + { 564 + return ['commit']; 565 + } 566 + 567 + public function collections(): ?array 568 + { 569 + return [ 570 + 'app.offprint.beta.publication', 571 + 'app.offprint.beta.post', 572 + ]; 573 + } 574 + 575 + public function shouldQueue(): bool 576 + { 577 + return true; 578 + } 579 + 580 + public function handle(SignalEvent $event): void 581 + { 582 + $collection = $event->getCollection(); 583 + $operation = $event->getOperation(); 584 + 585 + if ($collection === 'app.offprint.beta.publication') { 586 + $this->handlePublication($event, $operation); 587 + } else { 588 + $this->handlePost($event, $operation); 589 + } 590 + } 591 + 592 + private function handlePublication(SignalEvent $event, SignalCommitOperation $operation): void 593 + { 594 + if ($operation === SignalCommitOperation::Delete) { 595 + Publication::where('did', $event->did) 596 + ->where('rkey', $event->commit->rkey) 597 + ->delete(); 598 + return; 599 + } 600 + 601 + $record = $event->getRecord(); 602 + 603 + Publication::updateOrCreate( 604 + [ 605 + 'did' => $event->did, 606 + 'rkey' => $event->commit->rkey, 607 + ], 608 + [ 609 + 'title' => $record->title ?? '', 610 + 'description' => $record->description ?? null, 611 + 'created_at' => $record->createdAt ?? now(), 612 + ] 613 + ); 614 + } 615 + 616 + private function handlePost(SignalEvent $event, SignalCommitOperation $operation): void 617 + { 618 + // Handle custom post records 619 + } 620 + } 621 + ``` 622 + 623 + **Use case:** Build AT Protocol AppViews with custom collections. 624 + 625 + ## Rate-Limited API Integration 626 + 627 + Integrate with external APIs respecting rate limits. 628 + 629 + ```php 630 + <?php 631 + 632 + namespace App\Signals; 633 + 634 + use App\Services\ExternalAPIService; 635 + use Illuminate\Support\Facades\RateLimiter; 636 + use SocialDept\Signal\Events\SignalEvent; 637 + use SocialDept\Signal\Signals\Signal; 638 + 639 + class APIIntegrationSignal extends Signal 640 + { 641 + public function __construct( 642 + private ExternalAPIService $api 643 + ) {} 644 + 645 + public function eventTypes(): array 646 + { 647 + return ['commit']; 648 + } 649 + 650 + public function collections(): ?array 651 + { 652 + return ['app.bsky.feed.post']; 653 + } 654 + 655 + public function shouldQueue(): bool 656 + { 657 + return true; 658 + } 659 + 660 + public function handle(SignalEvent $event): void 661 + { 662 + $record = $event->getRecord(); 663 + 664 + // Rate limit: 100 calls per minute 665 + $executed = RateLimiter::attempt( 666 + 'external-api', 667 + $perMinute = 100, 668 + function () use ($event, $record) { 669 + $this->api->sendPost([ 670 + 'author' => $event->did, 671 + 'text' => $record->text ?? '', 672 + 'timestamp' => $event->getTimestamp(), 673 + ]); 674 + } 675 + ); 676 + 677 + if (!$executed) { 678 + // Re-queue for later 679 + dispatch(fn() => $this->handle($event)) 680 + ->delay(now()->addMinutes(1)); 681 + } 682 + } 683 + } 684 + ``` 685 + 686 + **Use case:** Mirror content to external platforms with rate limiting. 687 + 688 + ## Multi-Collection Analytics 689 + 690 + Track engagement across multiple collection types. 691 + 692 + ```php 693 + <?php 694 + 695 + namespace App\Signals; 696 + 697 + use App\Models\UserMetrics; 698 + use SocialDept\Signal\Enums\SignalCommitOperation; 699 + use SocialDept\Signal\Events\SignalEvent; 700 + use SocialDept\Signal\Signals\Signal; 701 + 702 + class UserMetricsSignal extends Signal 703 + { 704 + public function eventTypes(): array 705 + { 706 + return ['commit']; 707 + } 708 + 709 + public function collections(): ?array 710 + { 711 + return ['app.bsky.feed.*', 'app.bsky.graph.*']; 712 + } 713 + 714 + public function operations(): ?array 715 + { 716 + return [SignalCommitOperation::Create]; 717 + } 718 + 719 + public function shouldQueue(): bool 720 + { 721 + return true; 722 + } 723 + 724 + public function handle(SignalEvent $event): void 725 + { 726 + $collection = $event->getCollection(); 727 + 728 + $metrics = UserMetrics::firstOrCreate( 729 + ['did' => $event->did], 730 + ['total_posts' => 0, 'total_likes' => 0, 'total_follows' => 0] 731 + ); 732 + 733 + match ($collection) { 734 + 'app.bsky.feed.post' => $metrics->increment('total_posts'), 735 + 'app.bsky.feed.like' => $metrics->increment('total_likes'), 736 + 'app.bsky.graph.follow' => $metrics->increment('total_follows'), 737 + default => null, 738 + }; 739 + 740 + $metrics->touch('last_activity_at'); 741 + } 742 + } 743 + ``` 744 + 745 + **Use case:** User activity metrics and leaderboards. 746 + 747 + ## Performance Tips 748 + 749 + ### Batch Database Operations 750 + 751 + ```php 752 + public function handle(SignalEvent $event): void 753 + { 754 + // Bad - individual inserts 755 + Post::create([...]); 756 + 757 + // Good - batch inserts 758 + $posts = Cache::get('pending_posts', []); 759 + $posts[] = [...]; 760 + 761 + if (count($posts) >= 100) { 762 + Post::insert($posts); 763 + Cache::forget('pending_posts'); 764 + } else { 765 + Cache::put('pending_posts', $posts, now()->addMinutes(5)); 766 + } 767 + } 768 + ``` 769 + 770 + ### Use Queues for Heavy Operations 771 + 772 + ```php 773 + public function shouldQueue(): bool 774 + { 775 + // Queue if operation takes > 100ms 776 + return true; 777 + } 778 + ``` 779 + 780 + ### Add Indexes for Filtering 781 + 782 + ```php 783 + // Migration for fast lookups 784 + Schema::table('posts', function (Blueprint $table) { 785 + $table->index(['did', 'rkey']); 786 + $table->index('created_at'); 787 + }); 788 + ``` 789 + 790 + ## Next Steps 791 + 792 + - **[Review signal architecture →](signals.md)** - Understand Signal structure 793 + - **[Learn about filtering →](filtering.md)** - Master event filtering 794 + - **[Explore queue integration →](queues.md)** - Build high-performance Signals 795 + - **[Configure your setup →](configuration.md)** - Optimize configuration
+704
docs/filtering.md
··· 1 + # Filtering Events 2 + 3 + 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. 4 + 5 + ## Why Filter? 6 + 7 + The AT Protocol generates millions of events per hour. Without filtering: 8 + 9 + - Your Signals would process every event (slow and expensive) 10 + - Your database would fill with irrelevant data 11 + - Your queues would be overwhelmed 12 + - Your costs would skyrocket 13 + 14 + Filtering lets you focus on what matters. 15 + 16 + ## Filter Layers 17 + 18 + Signal provides four filtering layers, applied in order: 19 + 20 + 1. **Event Type Filtering** - Which kind of events (commit, identity, account) 21 + 2. **Collection Filtering** - Which AT Protocol collections 22 + 3. **Operation Filtering** - Which operations (create, update, delete) 23 + 4. **DID Filtering** - Which users 24 + 5. **Custom Filtering** - Your own logic 25 + 26 + ## Event Type Filtering 27 + 28 + The most basic filter - required for all Signals. 29 + 30 + ### Available Event Types 31 + 32 + ```php 33 + use SocialDept\Signal\Enums\SignalEventType; 34 + 35 + public function eventTypes(): array 36 + { 37 + return [SignalEventType::Commit]; // Most common 38 + // Or: return ['commit']; 39 + } 40 + ``` 41 + 42 + **Three event types:** 43 + 44 + | Type | Description | Use Cases | 45 + |------------|--------------------|----------------------------------------| 46 + | `commit` | Repository changes | Posts, likes, follows, profile updates | 47 + | `identity` | Handle changes | Username updates, account migrations | 48 + | `account` | Account status | Deactivation, suspension | 49 + 50 + ### Multiple Event Types 51 + 52 + Listen to multiple types in one Signal: 53 + 54 + ```php 55 + public function eventTypes(): array 56 + { 57 + return [ 58 + SignalEventType::Commit, 59 + SignalEventType::Identity, 60 + ]; 61 + } 62 + ``` 63 + 64 + Then check the type in your handler: 65 + 66 + ```php 67 + public function handle(SignalEvent $event): void 68 + { 69 + if ($event->isCommit()) { 70 + $this->handleCommit($event); 71 + } 72 + 73 + if ($event->isIdentity()) { 74 + $this->handleIdentity($event); 75 + } 76 + } 77 + ``` 78 + 79 + ## Collection Filtering 80 + 81 + Collections represent different types of data in the AT Protocol. 82 + 83 + ### Basic Collection Filter 84 + 85 + ```php 86 + public function collections(): ?array 87 + { 88 + return ['app.bsky.feed.post']; 89 + } 90 + ``` 91 + 92 + ### No Filter (All Collections) 93 + 94 + Return `null` to process all collections: 95 + 96 + ```php 97 + public function collections(): ?array 98 + { 99 + return null; // Handle everything 100 + } 101 + ``` 102 + 103 + ### Multiple Collections 104 + 105 + ```php 106 + public function collections(): ?array 107 + { 108 + return [ 109 + 'app.bsky.feed.post', 110 + 'app.bsky.feed.like', 111 + 'app.bsky.feed.repost', 112 + ]; 113 + } 114 + ``` 115 + 116 + ### Wildcard Patterns 117 + 118 + Use `*` to match multiple collections: 119 + 120 + ```php 121 + public function collections(): ?array 122 + { 123 + return ['app.bsky.feed.*']; 124 + } 125 + ``` 126 + 127 + **This matches:** 128 + - `app.bsky.feed.post` 129 + - `app.bsky.feed.like` 130 + - `app.bsky.feed.repost` 131 + - Any other `app.bsky.feed.*` collection 132 + 133 + ### Common Collection Patterns 134 + 135 + | Pattern | Matches | Use Case | 136 + |--------------------|-------------------------|------------------------| 137 + | `app.bsky.feed.*` | All feed interactions | Posts, likes, reposts | 138 + | `app.bsky.graph.*` | All social graph | Follows, blocks, mutes | 139 + | `app.bsky.actor.*` | All profile changes | Profile updates | 140 + | `app.bsky.*` | All Bluesky collections | Everything Bluesky | 141 + | `app.yourapp.*` | Your custom collections | Custom AppView | 142 + 143 + ### Mixing Exact and Wildcards 144 + 145 + Combine exact matches with wildcards: 146 + 147 + ```php 148 + public function collections(): ?array 149 + { 150 + return [ 151 + 'app.bsky.feed.post', // Exact: only posts 152 + 'app.bsky.graph.*', // Wildcard: all graph events 153 + 'app.myapp.custom.record', // Exact: custom collection 154 + ]; 155 + } 156 + ``` 157 + 158 + ### Standard Bluesky Collections 159 + 160 + **Feed Collections** (`app.bsky.feed.*`): 161 + - `app.bsky.feed.post` - Posts (text, images, videos) 162 + - `app.bsky.feed.like` - Likes on posts 163 + - `app.bsky.feed.repost` - Reposts (shares) 164 + - `app.bsky.feed.threadgate` - Thread reply controls 165 + - `app.bsky.feed.generator` - Custom feed generators 166 + 167 + **Graph Collections** (`app.bsky.graph.*`): 168 + - `app.bsky.graph.follow` - Follow relationships 169 + - `app.bsky.graph.block` - Blocked users 170 + - `app.bsky.graph.list` - User lists 171 + - `app.bsky.graph.listitem` - List memberships 172 + - `app.bsky.graph.listblock` - List blocks 173 + 174 + **Actor Collections** (`app.bsky.actor.*`): 175 + - `app.bsky.actor.profile` - User profiles 176 + 177 + **Labeler Collections** (`app.bsky.labeler.*`): 178 + - `app.bsky.labeler.service` - Labeler services 179 + 180 + ### Important: Jetstream vs Firehose Filtering 181 + 182 + **Jetstream Mode:** 183 + - Exact collection names are sent to server for filtering (efficient) 184 + - Wildcards work client-side only (you receive more data) 185 + 186 + **Firehose Mode:** 187 + - All filtering is client-side 188 + - Wildcards work normally (no difference in data received) 189 + 190 + [Learn more about modes →](modes.md) 191 + 192 + ### Custom Collections (AppViews) 193 + 194 + Filter your own custom collections: 195 + 196 + ```php 197 + public function collections(): ?array 198 + { 199 + return [ 200 + 'app.offprint.beta.publication', 201 + 'app.offprint.beta.post', 202 + ]; 203 + } 204 + ``` 205 + 206 + ## Operation Filtering 207 + 208 + Filter by operation type (only applies to commit events). 209 + 210 + ### Available Operations 211 + 212 + ```php 213 + use SocialDept\Signal\Enums\SignalCommitOperation; 214 + 215 + public function operations(): ?array 216 + { 217 + return [SignalCommitOperation::Create]; 218 + // Or: return ['create']; 219 + } 220 + ``` 221 + 222 + **Three operation types:** 223 + 224 + | Operation | Description | Example | 225 + |-----------|------------------|-----------------| 226 + | `create` | New records | Creating a post | 227 + | `update` | Modified records | Editing a post | 228 + | `delete` | Removed records | Deleting a post | 229 + 230 + ### No Filter (All Operations) 231 + 232 + ```php 233 + public function operations(): ?array 234 + { 235 + return null; // Handle all operations 236 + } 237 + ``` 238 + 239 + ### Multiple Operations 240 + 241 + ```php 242 + public function operations(): ?array 243 + { 244 + return [ 245 + SignalCommitOperation::Create, 246 + SignalCommitOperation::Update, 247 + ]; 248 + // Or: return ['create', 'update']; 249 + } 250 + ``` 251 + 252 + ### Common Patterns 253 + 254 + **Only track new content:** 255 + ```php 256 + public function operations(): ?array 257 + { 258 + return [SignalCommitOperation::Create]; 259 + } 260 + ``` 261 + 262 + **Track modifications:** 263 + ```php 264 + public function operations(): ?array 265 + { 266 + return [SignalCommitOperation::Update]; 267 + } 268 + ``` 269 + 270 + **Cleanup on deletions:** 271 + ```php 272 + public function operations(): ?array 273 + { 274 + return [SignalCommitOperation::Delete]; 275 + } 276 + ``` 277 + 278 + ### Checking Operations in Handler 279 + 280 + You can also check operation type in your handler: 281 + 282 + ```php 283 + public function handle(SignalEvent $event): void 284 + { 285 + $operation = $event->getOperation(); 286 + 287 + // Using enum 288 + if ($operation === SignalCommitOperation::Create) { 289 + $this->createRecord($event); 290 + } 291 + 292 + // Using commit helper 293 + if ($event->commit->isCreate()) { 294 + $this->createRecord($event); 295 + } 296 + 297 + if ($event->commit->isUpdate()) { 298 + $this->updateRecord($event); 299 + } 300 + 301 + if ($event->commit->isDelete()) { 302 + $this->deleteRecord($event); 303 + } 304 + } 305 + ``` 306 + 307 + ## DID Filtering 308 + 309 + Filter events by specific users (DIDs). 310 + 311 + ### Basic DID Filter 312 + 313 + ```php 314 + public function dids(): ?array 315 + { 316 + return [ 317 + 'did:plc:z72i7hdynmk6r22z27h6tvur', 318 + ]; 319 + } 320 + ``` 321 + 322 + ### No Filter (All Users) 323 + 324 + ```php 325 + public function dids(): ?array 326 + { 327 + return null; // Handle all users 328 + } 329 + ``` 330 + 331 + ### Multiple DIDs 332 + 333 + ```php 334 + public function dids(): ?array 335 + { 336 + return [ 337 + 'did:plc:z72i7hdynmk6r22z27h6tvur', 338 + 'did:plc:ragtjsm2j2vknwkz3zp4oxrd', 339 + ]; 340 + } 341 + ``` 342 + 343 + ### Use Cases 344 + 345 + **Monitor specific accounts:** 346 + ```php 347 + // Track posts from specific content creators 348 + public function collections(): ?array 349 + { 350 + return ['app.bsky.feed.post']; 351 + } 352 + 353 + public function dids(): ?array 354 + { 355 + return [ 356 + 'did:plc:z72i7hdynmk6r22z27h6tvur', // Creator 1 357 + 'did:plc:ragtjsm2j2vknwkz3zp4oxrd', // Creator 2 358 + ]; 359 + } 360 + ``` 361 + 362 + **Dynamic DID filtering:** 363 + ```php 364 + use App\Models\MonitoredAccount; 365 + 366 + public function dids(): ?array 367 + { 368 + return MonitoredAccount::pluck('did')->toArray(); 369 + } 370 + ``` 371 + 372 + ## Custom Filtering 373 + 374 + Implement complex filtering logic with `shouldHandle()`. 375 + 376 + ### Basic Custom Filter 377 + 378 + ```php 379 + public function shouldHandle(SignalEvent $event): bool 380 + { 381 + // Only handle posts with images 382 + if ($event->isCommit() && $event->getCollection() === 'app.bsky.feed.post') { 383 + $record = $event->getRecord(); 384 + return isset($record->embed); 385 + } 386 + 387 + return true; 388 + } 389 + ``` 390 + 391 + ### Advanced Examples 392 + 393 + **Filter by text content:** 394 + ```php 395 + public function shouldHandle(SignalEvent $event): bool 396 + { 397 + $record = $event->getRecord(); 398 + 399 + if (!isset($record->text)) { 400 + return false; 401 + } 402 + 403 + // Only handle posts mentioning "Laravel" 404 + return str_contains($record->text, 'Laravel'); 405 + } 406 + ``` 407 + 408 + **Filter by language:** 409 + ```php 410 + public function shouldHandle(SignalEvent $event): bool 411 + { 412 + $record = $event->getRecord(); 413 + 414 + // Only handle English posts 415 + return ($record->langs[0] ?? null) === 'en'; 416 + } 417 + ``` 418 + 419 + **Filter by engagement:** 420 + ```php 421 + use App\Services\EngagementCalculator; 422 + 423 + public function shouldHandle(SignalEvent $event): bool 424 + { 425 + $engagement = EngagementCalculator::calculate($event); 426 + 427 + // Only handle high-engagement content 428 + return $engagement > 100; 429 + } 430 + ``` 431 + 432 + **Time-based filtering:** 433 + ```php 434 + public function shouldHandle(SignalEvent $event): bool 435 + { 436 + $timestamp = $event->getTimestamp(); 437 + 438 + // Only handle events from the last hour 439 + return $timestamp->isAfter(now()->subHour()); 440 + } 441 + ``` 442 + 443 + ## Combining Filters 444 + 445 + Stack multiple filter layers for precise targeting: 446 + 447 + ```php 448 + class HighEngagementPostsSignal extends Signal 449 + { 450 + // Layer 1: Event type 451 + public function eventTypes(): array 452 + { 453 + return ['commit']; 454 + } 455 + 456 + // Layer 2: Collection 457 + public function collections(): ?array 458 + { 459 + return ['app.bsky.feed.post']; 460 + } 461 + 462 + // Layer 3: Operation 463 + public function operations(): ?array 464 + { 465 + return [SignalCommitOperation::Create]; 466 + } 467 + 468 + // Layer 4: Custom logic 469 + public function shouldHandle(SignalEvent $event): bool 470 + { 471 + $record = $event->getRecord(); 472 + 473 + // Must have text 474 + if (!isset($record->text)) { 475 + return false; 476 + } 477 + 478 + // Must be longer than 100 characters 479 + if (strlen($record->text) < 100) { 480 + return false; 481 + } 482 + 483 + // Must have media 484 + if (!isset($record->embed)) { 485 + return false; 486 + } 487 + 488 + return true; 489 + } 490 + 491 + public function handle(SignalEvent $event): void 492 + { 493 + // Only high-quality posts make it here 494 + } 495 + } 496 + ``` 497 + 498 + ## Performance Considerations 499 + 500 + ### Server-Side vs Client-Side Filtering 501 + 502 + **Jetstream Mode (Server-Side):** 503 + - Collections filter applied on server (efficient) 504 + - Only receives matching events 505 + - Lower bandwidth usage 506 + 507 + ```php 508 + // These collections are sent to Jetstream server 509 + public function collections(): ?array 510 + { 511 + return ['app.bsky.feed.post', 'app.bsky.feed.like']; 512 + } 513 + ``` 514 + 515 + **Firehose Mode (Client-Side):** 516 + - All filtering happens in your application 517 + - Receives all events (higher bandwidth) 518 + - More control but higher cost 519 + 520 + [Learn more about modes →](modes.md) 521 + 522 + ### Filter Early 523 + 524 + Apply the most restrictive filters first: 525 + 526 + ```php 527 + // Good - filters early 528 + public function eventTypes(): array 529 + { 530 + return ['commit']; // Narrows to commits only 531 + } 532 + 533 + public function collections(): ?array 534 + { 535 + return ['app.bsky.feed.post']; // Further narrows to posts 536 + } 537 + 538 + // Less ideal - too broad 539 + public function eventTypes(): array 540 + { 541 + return ['commit', 'identity', 'account']; // Too many events 542 + } 543 + 544 + public function shouldHandle(SignalEvent $event): bool 545 + { 546 + // Filtering everything in custom logic (expensive) 547 + return $event->isCommit() && $event->getCollection() === 'app.bsky.feed.post'; 548 + } 549 + ``` 550 + 551 + ### Avoid Heavy Logic in shouldHandle() 552 + 553 + Keep custom filtering lightweight: 554 + 555 + ```php 556 + // Good - lightweight checks 557 + public function shouldHandle(SignalEvent $event): bool 558 + { 559 + $record = $event->getRecord(); 560 + return isset($record->text) && strlen($record->text) > 10; 561 + } 562 + 563 + // Less ideal - heavy database queries 564 + public function shouldHandle(SignalEvent $event): bool 565 + { 566 + // Database query on every event (slow!) 567 + return User::where('did', $event->did)->exists(); 568 + } 569 + ``` 570 + 571 + If you need heavy logic, use queues: 572 + 573 + ```php 574 + public function shouldQueue(): bool 575 + { 576 + return true; // Move heavy work to queue 577 + } 578 + ``` 579 + 580 + ## Common Filter Patterns 581 + 582 + ### Track All Activity from Specific Users 583 + 584 + ```php 585 + public function eventTypes(): array 586 + { 587 + return ['commit']; 588 + } 589 + 590 + public function dids(): ?array 591 + { 592 + return [ 593 + 'did:plc:z72i7hdynmk6r22z27h6tvur', 594 + ]; 595 + } 596 + ``` 597 + 598 + ### Monitor All Feed Activity 599 + 600 + ```php 601 + public function eventTypes(): array 602 + { 603 + return ['commit']; 604 + } 605 + 606 + public function collections(): ?array 607 + { 608 + return ['app.bsky.feed.*']; 609 + } 610 + ``` 611 + 612 + ### Track Only New Posts 613 + 614 + ```php 615 + public function eventTypes(): array 616 + { 617 + return ['commit']; 618 + } 619 + 620 + public function collections(): ?array 621 + { 622 + return ['app.bsky.feed.post']; 623 + } 624 + 625 + public function operations(): ?array 626 + { 627 + return [SignalCommitOperation::Create]; 628 + } 629 + ``` 630 + 631 + ### Monitor Content Deletions 632 + 633 + ```php 634 + public function eventTypes(): array 635 + { 636 + return ['commit']; 637 + } 638 + 639 + public function operations(): ?array 640 + { 641 + return [SignalCommitOperation::Delete]; 642 + } 643 + ``` 644 + 645 + ### Track Profile Changes 646 + 647 + ```php 648 + public function eventTypes(): array 649 + { 650 + return ['commit']; 651 + } 652 + 653 + public function collections(): ?array 654 + { 655 + return ['app.bsky.actor.profile']; 656 + } 657 + ``` 658 + 659 + ### Monitor Handle Changes 660 + 661 + ```php 662 + public function eventTypes(): array 663 + { 664 + return ['identity']; 665 + } 666 + ``` 667 + 668 + ## Debugging Filters 669 + 670 + ### Log What's Being Filtered 671 + 672 + ```php 673 + public function shouldHandle(SignalEvent $event): bool 674 + { 675 + $shouldHandle = $this->myCustomLogic($event); 676 + 677 + if (!$shouldHandle) { 678 + Log::debug('Event filtered out', [ 679 + 'signal' => static::class, 680 + 'did' => $event->did, 681 + 'collection' => $event->getCollection(), 682 + 'reason' => 'Failed custom logic', 683 + ]); 684 + } 685 + 686 + return $shouldHandle; 687 + } 688 + ``` 689 + 690 + ### Test Your Filters 691 + 692 + ```bash 693 + php artisan signal:test YourSignal 694 + ``` 695 + 696 + This runs your Signal with sample data to verify filtering works correctly. 697 + 698 + [Learn more about testing →](testing.md) 699 + 700 + ## Next Steps 701 + 702 + - **[Understand Jetstream vs Firehose →](modes.md)** - Choose the right mode for your filters 703 + - **[Learn about queue integration →](queues.md)** - Handle high-volume filtered events 704 + - **[See real-world examples →](examples.md)** - Learn from production filter patterns
+188
docs/installation.md
··· 1 + # Installation 2 + 3 + Signal is designed to be installed quickly and easily in any Laravel 11+ application. 4 + 5 + ## Requirements 6 + 7 + Before installing Signal, ensure your environment meets these requirements: 8 + 9 + - **PHP 8.2 or higher** 10 + - **Laravel 11.0 or higher** 11 + - **WebSocket support** (enabled by default in most environments) 12 + - **Database** (for cursor storage) 13 + 14 + ## Composer Installation 15 + 16 + Install Signal via Composer: 17 + 18 + ```bash 19 + composer require socialdept/signal 20 + ``` 21 + 22 + ## Quick Setup 23 + 24 + Run the installation command to set up everything automatically: 25 + 26 + ```bash 27 + php artisan signal:install 28 + ``` 29 + 30 + This interactive command will: 31 + 32 + 1. Publish the configuration file to `config/signal.php` 33 + 2. Publish database migrations for cursor storage 34 + 3. Ask if you'd like to run migrations immediately 35 + 4. Display next steps and helpful information 36 + 37 + ### What Gets Created 38 + 39 + After installation, you'll have: 40 + 41 + - **Configuration file**: `config/signal.php` - All Signal settings 42 + - **Migration**: `database/migrations/2024_01_01_000000_create_signal_cursors_table.php` - Cursor storage 43 + - **Signal directory**: `app/Signals/` - Where your Signals live (created when you make your first Signal) 44 + 45 + ## Manual Installation 46 + 47 + If you prefer more control, you can install manually: 48 + 49 + ### 1. Publish Configuration 50 + 51 + ```bash 52 + php artisan vendor:publish --tag=signal-config 53 + ``` 54 + 55 + This creates `config/signal.php` with all available options. 56 + 57 + ### 2. Publish Migrations 58 + 59 + ```bash 60 + php artisan vendor:publish --tag=signal-migrations 61 + ``` 62 + 63 + This creates the cursor storage migration in `database/migrations/`. 64 + 65 + ### 3. Run Migrations 66 + 67 + ```bash 68 + php artisan migrate 69 + ``` 70 + 71 + This creates the `signal_cursors` table for resuming from last position after disconnections. 72 + 73 + ## Environment Configuration 74 + 75 + Add Signal configuration to your `.env` file: 76 + 77 + ```env 78 + # Consumer Mode (jetstream or firehose) 79 + SIGNAL_MODE=jetstream 80 + 81 + # Jetstream URL (if using jetstream mode) 82 + SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network 83 + 84 + # Firehose Host (if using firehose mode) 85 + SIGNAL_FIREHOSE_HOST=bsky.network 86 + 87 + # Optional: Cursor Storage Driver (database, redis, or file) 88 + SIGNAL_CURSOR_STORAGE=database 89 + 90 + # Optional: Queue Configuration 91 + SIGNAL_QUEUE_CONNECTION=redis 92 + SIGNAL_QUEUE=signal 93 + ``` 94 + 95 + ## Choosing Your Mode 96 + 97 + Signal supports two modes for consuming events. Choose based on your use case: 98 + 99 + ### Jetstream Mode (Recommended) 100 + 101 + Best for most applications: 102 + 103 + ```env 104 + SIGNAL_MODE=jetstream 105 + SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network 106 + ``` 107 + 108 + **Advantages:** 109 + - Simplified JSON events (easy to work with) 110 + - Server-side collection filtering (efficient) 111 + - Lower bandwidth and processing overhead 112 + 113 + ### Firehose Mode 114 + 115 + Best for comprehensive indexing and raw data access: 116 + 117 + ```env 118 + SIGNAL_MODE=firehose 119 + SIGNAL_FIREHOSE_HOST=bsky.network 120 + ``` 121 + 122 + **Advantages:** 123 + - Access to raw CBOR/CAR data 124 + - Full AT Protocol event stream 125 + - Complete control over event processing 126 + 127 + **Trade-offs:** 128 + - Client-side filtering only (higher bandwidth) 129 + - More processing overhead 130 + 131 + [Learn more about choosing the right mode →](modes.md) 132 + 133 + ## Verify Installation 134 + 135 + Check that Signal is installed correctly: 136 + 137 + ```bash 138 + php artisan signal:list 139 + ``` 140 + 141 + This should display available Signals (initially none until you create them). 142 + 143 + ## Next Steps 144 + 145 + Now that Signal is installed, you're ready to start building: 146 + 147 + 1. **[Create your first Signal →](quickstart.md)** 148 + 2. **[Learn about Signal architecture →](signals.md)** 149 + 3. **[Understand filtering options →](filtering.md)** 150 + 151 + ## Troubleshooting 152 + 153 + ### Migration Already Exists 154 + 155 + If you see "migration already exists" when running `signal:install`, you've likely already installed Signal. You can safely skip this step. 156 + 157 + ### WebSocket Connection Issues 158 + 159 + If you experience WebSocket connection issues: 160 + 161 + 1. Verify your firewall allows WebSocket connections 162 + 2. Check that your hosting environment supports WebSockets 163 + 3. Try switching Jetstream endpoints (US East vs US West) 164 + 165 + ### Permission Errors 166 + 167 + If you encounter permission errors with cursor storage: 168 + 169 + - **Database mode**: Ensure database connection is configured correctly 170 + - **Redis mode**: Verify Redis connection is available 171 + - **File mode**: Check that Laravel has write permissions to `storage/app/signal/` 172 + 173 + ## Uninstallation 174 + 175 + To remove Signal from your application: 176 + 177 + ```bash 178 + # Remove the package 179 + composer remove socialdept/signal 180 + 181 + # Optionally, rollback migrations 182 + php artisan migrate:rollback 183 + ``` 184 + 185 + You can also manually delete: 186 + - `config/signal.php` 187 + - `app/Signals/` directory 188 + - Signal-related migrations
+493
docs/modes.md
··· 1 + # Jetstream vs Firehose 2 + 3 + Signal supports two modes for consuming AT Protocol events. Understanding the differences is crucial for building efficient, scalable applications. 4 + 5 + ## Quick Comparison 6 + 7 + | Feature | Jetstream | Firehose | 8 + |------------------|-------------------|------------------------| 9 + | **Event Format** | Simplified JSON | Raw CBOR/CAR | 10 + | **Filtering** | Server-side | Client-side | 11 + | **Bandwidth** | Lower | Higher | 12 + | **Processing** | Lighter | Heavier | 13 + | **Best For** | Most applications | Comprehensive indexing | 14 + 15 + ## Jetstream Mode 16 + 17 + Jetstream is a **simplified, JSON-based event stream** built on top of the AT Protocol Firehose. 18 + 19 + ### When to Use Jetstream 20 + 21 + Choose Jetstream if you're: 22 + 23 + - Building production applications where efficiency matters 24 + - Concerned about bandwidth and server costs 25 + - Processing high volumes of events 26 + - Want server-side filtering for reduced bandwidth 27 + 28 + ### Configuration 29 + 30 + Set Jetstream as your mode in `.env`: 31 + 32 + ```env 33 + SIGNAL_MODE=jetstream 34 + SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network 35 + ``` 36 + 37 + ### Available Endpoints 38 + 39 + Jetstream has multiple regional endpoints: 40 + 41 + **US East (Default):** 42 + ```env 43 + SIGNAL_JETSTREAM_URL=wss://jetstream2.us-east.bsky.network 44 + ``` 45 + 46 + **US West:** 47 + ```env 48 + SIGNAL_JETSTREAM_URL=wss://jetstream1.us-west.bsky.network 49 + ``` 50 + 51 + Choose the endpoint closest to your server for best performance. 52 + 53 + ### Advantages 54 + 55 + **1. Simplified JSON Format** 56 + 57 + Events arrive as clean JSON objects: 58 + 59 + ```json 60 + { 61 + "did": "did:plc:z72i7hdynmk6r22z27h6tvur", 62 + "time_us": 1234567890, 63 + "kind": "commit", 64 + "commit": { 65 + "rev": "abc123", 66 + "operation": "create", 67 + "collection": "app.bsky.feed.post", 68 + "rkey": "3k2yihcrr2c2a", 69 + "record": { 70 + "text": "Hello World!", 71 + "createdAt": "2024-01-15T10:30:00Z" 72 + } 73 + } 74 + } 75 + ``` 76 + 77 + No complex parsing or decoding required. 78 + 79 + **2. Server-Side Filtering** 80 + 81 + Your collection filters are sent to Jetstream: 82 + 83 + ```php 84 + public function collections(): ?array 85 + { 86 + return ['app.bsky.feed.post', 'app.bsky.feed.like']; 87 + } 88 + ``` 89 + 90 + Jetstream only sends matching events, dramatically reducing bandwidth. 91 + 92 + **3. Lower Bandwidth** 93 + 94 + Only receive the events you care about: 95 + 96 + - **Jetstream**: Receive ~1,000 events/sec for specific collections 97 + - **Firehose**: Receive ~50,000 events/sec for everything 98 + 99 + **4. Lower Processing Overhead** 100 + 101 + JSON parsing is faster than CBOR/CAR decoding: 102 + 103 + - **Jetstream**: Simple JSON deserialization 104 + - **Firehose**: Complex CBOR/CAR decoding with `revolution/laravel-bluesky` 105 + 106 + ### Limitations 107 + 108 + **1. Client-Side Wildcards** 109 + 110 + Wildcard patterns work client-side only: 111 + 112 + ```php 113 + public function collections(): ?array 114 + { 115 + return ['app.bsky.feed.*']; // Still receives all collections 116 + } 117 + ``` 118 + 119 + The wildcard matching happens in your app, not on the server. 120 + 121 + ### Example Configuration 122 + 123 + ```php 124 + // config/signal.php 125 + return [ 126 + 'mode' => env('SIGNAL_MODE', 'jetstream'), 127 + 128 + 'jetstream' => [ 129 + 'websocket_url' => env( 130 + 'SIGNAL_JETSTREAM_URL', 131 + 'wss://jetstream2.us-east.bsky.network' 132 + ), 133 + ], 134 + ]; 135 + ``` 136 + 137 + ## Firehose Mode 138 + 139 + Firehose is the **raw AT Protocol event stream** with comprehensive support for all collections. 140 + 141 + ### When to Use Firehose 142 + 143 + Choose Firehose if you're: 144 + 145 + - Building comprehensive indexing systems 146 + - Developing AT Protocol infrastructure 147 + - Need access to raw CBOR/CAR data 148 + - Prefer client-side filtering control 149 + 150 + ### Configuration 151 + 152 + Set Firehose as your mode in `.env`: 153 + 154 + ```env 155 + SIGNAL_MODE=firehose 156 + SIGNAL_FIREHOSE_HOST=bsky.network 157 + ``` 158 + 159 + ### Advantages 160 + 161 + **1. Raw Event Access** 162 + 163 + Full access to raw AT Protocol data: 164 + 165 + ```php 166 + public function handle(SignalEvent $event): void 167 + { 168 + // Access raw CBOR/CAR data 169 + $cid = $event->commit->cid; 170 + $blocks = $event->commit->blocks; 171 + } 172 + ``` 173 + 174 + **2. Comprehensive Events** 175 + 176 + Every event from the AT Protocol network arrives: 177 + 178 + - All collections (standard and custom) 179 + - All operations (create, update, delete) 180 + - All metadata and context 181 + - Complete repository commits 182 + 183 + **3. Complete Control** 184 + 185 + Full access to raw AT Protocol data: 186 + 187 + - CID (Content Identifiers) 188 + - Block structures 189 + - CAR file data 190 + - Complete repository commits 191 + 192 + ### Trade-offs 193 + 194 + **1. Client-Side Filtering** 195 + 196 + All filtering happens in your application: 197 + 198 + ```php 199 + public function collections(): ?array 200 + { 201 + return ['app.bsky.feed.post']; // Still receives all events 202 + } 203 + ``` 204 + 205 + Your app receives everything and filters locally. 206 + 207 + **2. Higher Bandwidth** 208 + 209 + Receive the full event stream: 210 + 211 + - **~50,000+ events per second** during peak times 212 + - **~10-50 MB/s** of data throughput 213 + - Requires adequate network capacity 214 + 215 + **3. More Processing Overhead** 216 + 217 + Complex CBOR/CAR decoding: 218 + 219 + ```php 220 + // Signal automatically handles decoding using revolution/laravel-bluesky 221 + $record = $event->getRecord(); // Decoded from CBOR/CAR 222 + ``` 223 + 224 + Processing is more CPU-intensive than Jetstream's JSON. 225 + 226 + **4. Requires revolution/laravel-bluesky** 227 + 228 + Firehose mode depends on the `revolution/laravel-bluesky` package for decoding: 229 + 230 + ```bash 231 + composer require revolution/bluesky 232 + ``` 233 + 234 + Signal handles this dependency automatically. 235 + 236 + ### Example Configuration 237 + 238 + ```php 239 + // config/signal.php 240 + return [ 241 + 'mode' => env('SIGNAL_MODE', 'jetstream'), 242 + 243 + 'firehose' => [ 244 + 'host' => env('SIGNAL_FIREHOSE_HOST', 'bsky.network'), 245 + ], 246 + ]; 247 + ``` 248 + 249 + The WebSocket URL is constructed as: 250 + ``` 251 + wss://{host}/xrpc/com.atproto.sync.subscribeRepos 252 + ``` 253 + 254 + ## Choosing the Right Mode 255 + 256 + ### Decision Tree 257 + 258 + ``` 259 + Do you need raw CBOR/CAR access? 260 + ├─ Yes → Use Firehose 261 + └─ No 262 + 263 + Do you want server-side filtering? 264 + ├─ Yes → Use Jetstream (recommended) 265 + └─ No → Use Firehose 266 + ``` 267 + 268 + ### Use Case Examples 269 + 270 + **Social Media Analytics (Jetstream)** 271 + 272 + ```php 273 + // Efficient monitoring with server-side filtering 274 + public function collections(): ?array 275 + { 276 + return [ 277 + 'app.bsky.feed.post', 278 + 'app.bsky.feed.like', 279 + 'app.bsky.graph.follow', 280 + ]; 281 + } 282 + ``` 283 + 284 + **Content Moderation (Jetstream)** 285 + 286 + ```php 287 + // Standard content monitoring 288 + public function collections(): ?array 289 + { 290 + return ['app.bsky.feed.*']; 291 + } 292 + ``` 293 + 294 + **Comprehensive Indexer (Firehose)** 295 + 296 + ```php 297 + // Index everything with raw data access 298 + public function collections(): ?array 299 + { 300 + return null; // All collections 301 + } 302 + ``` 303 + 304 + ## Switching Between Modes 305 + 306 + You can switch modes without code changes: 307 + 308 + ### Option 1: Environment Variable 309 + 310 + ```env 311 + # Development - comprehensive testing 312 + SIGNAL_MODE=firehose 313 + 314 + # Production - efficient processing 315 + SIGNAL_MODE=jetstream 316 + ``` 317 + 318 + ### Option 2: Runtime Configuration 319 + 320 + ```php 321 + use SocialDept\Signal\Facades\Signal; 322 + 323 + // Set mode dynamically 324 + config(['signal.mode' => 'jetstream']); 325 + 326 + Signal::start(); 327 + ``` 328 + 329 + ## Performance Comparison 330 + 331 + ### Bandwidth Usage 332 + 333 + **Processing 1 hour of posts:** 334 + 335 + | Mode | Data Received | Bandwidth | 336 + |-----------|-------------------|-----------| 337 + | Jetstream | ~50,000 events | ~10 MB | 338 + | Firehose | ~5,000,000 events | ~500 MB | 339 + 340 + **Savings:** 50x reduction with Jetstream 341 + 342 + ### CPU Usage 343 + 344 + **Processing same events:** 345 + 346 + | Mode | CPU Usage | Processing Time | 347 + |-----------|-----------|-----------------| 348 + | Jetstream | ~5% | 0.1ms per event | 349 + | Firehose | ~20% | 0.4ms per event | 350 + 351 + **Savings:** 4x more efficient with Jetstream 352 + 353 + ### Cost Implications 354 + 355 + For a medium-traffic application: 356 + 357 + | Mode | Monthly Bandwidth | Est. Cost* | 358 + |-----------|-------------------|------------| 359 + | Jetstream | ~20 GB | ~$2 | 360 + | Firehose | ~10 TB | ~$1000 | 361 + 362 + *Estimates vary by provider and usage 363 + 364 + ## Best Practices 365 + 366 + ### Start with Jetstream 367 + 368 + Start with Jetstream for most applications: 369 + 370 + ```env 371 + SIGNAL_MODE=jetstream 372 + ``` 373 + 374 + Switch to Firehose only if you need raw CBOR/CAR access. 375 + 376 + ### Use Firehose for Development 377 + 378 + Test with Firehose in development to see all events: 379 + 380 + ```env 381 + # .env.local 382 + SIGNAL_MODE=firehose 383 + 384 + # .env.production 385 + SIGNAL_MODE=jetstream 386 + ``` 387 + 388 + ### Monitor Performance 389 + 390 + Track your Signal's performance: 391 + 392 + ```php 393 + public function handle(SignalEvent $event): void 394 + { 395 + $start = microtime(true); 396 + 397 + // Your logic 398 + 399 + $duration = microtime(true) - $start; 400 + 401 + if ($duration > 0.1) { 402 + Log::warning('Slow signal processing', [ 403 + 'signal' => static::class, 404 + 'duration' => $duration, 405 + 'mode' => config('signal.mode'), 406 + ]); 407 + } 408 + } 409 + ``` 410 + 411 + ### Use Queues with Firehose 412 + 413 + Firehose generates high volume. Use queues to avoid blocking: 414 + 415 + ```php 416 + public function shouldQueue(): bool 417 + { 418 + // Queue when using Firehose 419 + return config('signal.mode') === 'firehose'; 420 + } 421 + ``` 422 + 423 + [Learn more about queue integration →](queues.md) 424 + 425 + ## Testing Both Modes 426 + 427 + Test your Signals work in both modes: 428 + 429 + ```bash 430 + # Test with Jetstream 431 + SIGNAL_MODE=jetstream php artisan signal:test MySignal 432 + 433 + # Test with Firehose 434 + SIGNAL_MODE=firehose php artisan signal:test MySignal 435 + ``` 436 + 437 + [Learn more about testing →](testing.md) 438 + 439 + ## Common Questions 440 + 441 + ### Can I use both modes simultaneously? 442 + 443 + No, each consumer runs in one mode. However, you can run multiple consumers: 444 + 445 + ```bash 446 + # Terminal 1 - Jetstream consumer 447 + SIGNAL_MODE=jetstream php artisan signal:consume 448 + 449 + # Terminal 2 - Firehose consumer 450 + SIGNAL_MODE=firehose php artisan signal:consume 451 + ``` 452 + 453 + ### Will my Signals break if I switch modes? 454 + 455 + Signals work in both modes without changes. The main difference is: 456 + - Jetstream provides server-side filtering (more efficient) 457 + - Firehose provides raw CBOR/CAR data access (more comprehensive) 458 + 459 + ### How do I know which mode I'm using? 460 + 461 + Check at runtime: 462 + 463 + ```php 464 + $mode = config('signal.mode'); // 'jetstream' or 'firehose' 465 + ``` 466 + 467 + Or via Facade: 468 + 469 + ```php 470 + use SocialDept\Signal\Facades\Signal; 471 + 472 + $mode = Signal::getMode(); 473 + ``` 474 + 475 + ### Can I switch modes while consuming? 476 + 477 + No, you must restart the consumer: 478 + 479 + ```bash 480 + # Stop current consumer (Ctrl+C) 481 + 482 + # Change mode 483 + # Edit .env: SIGNAL_MODE=firehose 484 + 485 + # Start new consumer 486 + php artisan signal:consume 487 + ``` 488 + 489 + ## Next Steps 490 + 491 + - **[Learn about queue integration →](queues.md)** - Handle high-volume events efficiently 492 + - **[Review configuration options →](configuration.md)** - Fine-tune your setup 493 + - **[See real-world examples →](examples.md)** - Learn from production patterns
+672
docs/queues.md
··· 1 + # Queue Integration 2 + 3 + Processing AT Protocol events can be resource-intensive. Signal's queue integration lets you handle events asynchronously, preventing bottlenecks and improving performance. 4 + 5 + ## Why Use Queues? 6 + 7 + ### Without Queues (Synchronous) 8 + 9 + ```php 10 + public function handle(SignalEvent $event): void 11 + { 12 + $this->performExpensiveAnalysis($event); // Blocks for 2 seconds 13 + $this->sendNotifications($event); // Blocks for 1 second 14 + $this->updateDatabase($event); // Blocks for 0.5 seconds 15 + } 16 + ``` 17 + 18 + **Problems:** 19 + - Consumer blocks while processing (3.5 seconds per event) 20 + - Events queue up during slow operations 21 + - Risk of disconnection during long processing 22 + - Can't scale horizontally 23 + - Memory issues with long-running processes 24 + 25 + ### With Queues (Asynchronous) 26 + 27 + ```php 28 + public function shouldQueue(): bool 29 + { 30 + return true; 31 + } 32 + 33 + public function handle(SignalEvent $event): void 34 + { 35 + $this->performExpensiveAnalysis($event); // Runs in background 36 + $this->sendNotifications($event); // Runs in background 37 + $this->updateDatabase($event); // Runs in background 38 + } 39 + ``` 40 + 41 + **Benefits:** 42 + - Consumer stays responsive 43 + - Processing happens in parallel 44 + - Scale by adding queue workers 45 + - Better memory management 46 + - Automatic retry on failures 47 + 48 + ## Basic Queue Configuration 49 + 50 + ### Enable Queueing 51 + 52 + Simply return `true` from `shouldQueue()`: 53 + 54 + ```php 55 + class MySignal extends Signal 56 + { 57 + public function eventTypes(): array 58 + { 59 + return ['commit']; 60 + } 61 + 62 + public function shouldQueue(): bool 63 + { 64 + return true; // Enable queuing 65 + } 66 + 67 + public function handle(SignalEvent $event): void 68 + { 69 + // This now runs in a queue job 70 + } 71 + } 72 + ``` 73 + 74 + That's it! Signal automatically: 75 + - Creates a queue job for each event 76 + - Serializes the event data 77 + - Dispatches to Laravel's queue system 78 + - Handles retries and failures 79 + 80 + ### Default Queue Configuration 81 + 82 + Signal uses your Laravel queue configuration: 83 + 84 + ```env 85 + # Default queue connection 86 + QUEUE_CONNECTION=redis 87 + 88 + # Signal-specific queue (optional) 89 + SIGNAL_QUEUE=signal 90 + 91 + # Signal queue connection (optional) 92 + SIGNAL_QUEUE_CONNECTION=redis 93 + ``` 94 + 95 + ## Customizing Queue Behavior 96 + 97 + ### Specify Queue Name 98 + 99 + Send events to a specific queue: 100 + 101 + ```php 102 + public function shouldQueue(): bool 103 + { 104 + return true; 105 + } 106 + 107 + public function queue(): string 108 + { 109 + return 'high-priority'; // Queue name 110 + } 111 + ``` 112 + 113 + Now your events go to the `high-priority` queue: 114 + 115 + ```bash 116 + php artisan queue:work --queue=high-priority 117 + ``` 118 + 119 + ### Specify Queue Connection 120 + 121 + Use a different queue connection: 122 + 123 + ```php 124 + public function shouldQueue(): bool 125 + { 126 + return true; 127 + } 128 + 129 + public function queueConnection(): string 130 + { 131 + return 'redis'; // Connection name 132 + } 133 + ``` 134 + 135 + ### Combine Queue Configuration 136 + 137 + ```php 138 + public function shouldQueue(): bool 139 + { 140 + return true; 141 + } 142 + 143 + public function queueConnection(): string 144 + { 145 + return 'redis'; 146 + } 147 + 148 + public function queue(): string 149 + { 150 + return 'signal-events'; 151 + } 152 + ``` 153 + 154 + ## Running Queue Workers 155 + 156 + ### Start a Worker 157 + 158 + Process queued events: 159 + 160 + ```bash 161 + php artisan queue:work 162 + ``` 163 + 164 + ### Process Specific Queue 165 + 166 + ```bash 167 + php artisan queue:work --queue=signal 168 + ``` 169 + 170 + ### Multiple Queues with Priority 171 + 172 + Process high-priority queue first: 173 + 174 + ```bash 175 + php artisan queue:work --queue=high-priority,default 176 + ``` 177 + 178 + ### Scale with Multiple Workers 179 + 180 + Run multiple workers for throughput: 181 + 182 + ```bash 183 + # Terminal 1 184 + php artisan queue:work --queue=signal 185 + 186 + # Terminal 2 187 + php artisan queue:work --queue=signal 188 + 189 + # Terminal 3 190 + php artisan queue:work --queue=signal 191 + ``` 192 + 193 + ### Supervisor Configuration 194 + 195 + For production, use Supervisor to manage workers: 196 + 197 + ```ini 198 + [program:signal-queue-worker] 199 + process_name=%(program_name)s_%(process_num)02d 200 + command=php /path/to/artisan queue:work --sleep=3 --tries=3 --queue=signal 201 + autostart=true 202 + autorestart=true 203 + stopasgroup=true 204 + killasgroup=true 205 + user=www-data 206 + numprocs=4 207 + redirect_stderr=true 208 + stdout_logfile=/path/to/logs/signal-worker.log 209 + stopwaitsecs=3600 210 + ``` 211 + 212 + This creates 4 workers processing the `signal` queue. 213 + 214 + ## Error Handling 215 + 216 + ### Failed Method 217 + 218 + Handle job failures: 219 + 220 + ```php 221 + public function shouldQueue(): bool 222 + { 223 + return true; 224 + } 225 + 226 + public function handle(SignalEvent $event): void 227 + { 228 + // Your logic that might fail 229 + $this->riskyOperation($event); 230 + } 231 + 232 + public function failed(SignalEvent $event, \Throwable $exception): void 233 + { 234 + Log::error('Signal processing failed', [ 235 + 'signal' => static::class, 236 + 'did' => $event->did, 237 + 'collection' => $event->getCollection(), 238 + 'error' => $exception->getMessage(), 239 + 'trace' => $exception->getTraceAsString(), 240 + ]); 241 + 242 + // Optional: Send alerts 243 + $this->notifyAdmin($exception); 244 + 245 + // Optional: Store for manual review 246 + FailedSignal::create([ 247 + 'event_data' => $event->toArray(), 248 + 'exception' => $exception->getMessage(), 249 + ]); 250 + } 251 + ``` 252 + 253 + ### Automatic Retries 254 + 255 + Laravel automatically retries failed jobs: 256 + 257 + ```bash 258 + # Retry up to 3 times 259 + php artisan queue:work --tries=3 260 + ``` 261 + 262 + Configure retry delay: 263 + 264 + ```php 265 + public function retryAfter(): int 266 + { 267 + return 60; // Wait 60 seconds before retry 268 + } 269 + ``` 270 + 271 + ### Exponential Backoff 272 + 273 + Increase delay between retries: 274 + 275 + ```php 276 + public function backoff(): array 277 + { 278 + return [10, 30, 60]; // 10s, then 30s, then 60s 279 + } 280 + ``` 281 + 282 + ## Performance Optimization 283 + 284 + ### Batch Processing 285 + 286 + Process multiple events at once: 287 + 288 + ```php 289 + use Illuminate\Support\Collection; 290 + 291 + class BatchPostSignal extends Signal 292 + { 293 + public function shouldQueue(): bool 294 + { 295 + return true; 296 + } 297 + 298 + public function handle(SignalEvent $event): void 299 + { 300 + // Collect events in cache 301 + $events = Cache::get('pending_posts', []); 302 + $events[] = $event->toArray(); 303 + 304 + Cache::put('pending_posts', $events, now()->addMinutes(5)); 305 + 306 + // Process in batches of 100 307 + if (count($events) >= 100) { 308 + $this->processBatch($events); 309 + Cache::forget('pending_posts'); 310 + } 311 + } 312 + 313 + private function processBatch(array $events): void 314 + { 315 + // Bulk insert, API calls, etc. 316 + } 317 + } 318 + ``` 319 + 320 + ### Conditional Queuing 321 + 322 + Queue only expensive operations: 323 + 324 + ```php 325 + public function shouldQueue(): bool 326 + { 327 + // Queue during high traffic 328 + return now()->hour >= 9 && now()->hour <= 17; 329 + } 330 + ``` 331 + 332 + Or based on event type: 333 + 334 + ```php 335 + public function handle(SignalEvent $event): void 336 + { 337 + if ($this->isExpensive($event)) { 338 + dispatch(function () use ($event) { 339 + $this->handleExpensive($event); 340 + })->onQueue('slow-operations'); 341 + } else { 342 + $this->handleQuick($event); 343 + } 344 + } 345 + ``` 346 + 347 + ### Rate Limiting 348 + 349 + Prevent overwhelming external APIs: 350 + 351 + ```php 352 + use Illuminate\Support\Facades\RateLimiter; 353 + 354 + public function handle(SignalEvent $event): void 355 + { 356 + RateLimiter::attempt( 357 + 'api-calls', 358 + $perMinute = 100, 359 + function () use ($event) { 360 + $this->callExternalAPI($event); 361 + } 362 + ); 363 + } 364 + ``` 365 + 366 + ## Common Patterns 367 + 368 + ### High-Volume Signal 369 + 370 + Process millions of events efficiently: 371 + 372 + ```php 373 + class HighVolumeSignal extends Signal 374 + { 375 + public function eventTypes(): array 376 + { 377 + return ['commit']; 378 + } 379 + 380 + public function collections(): ?array 381 + { 382 + return ['app.bsky.feed.post']; 383 + } 384 + 385 + public function shouldQueue(): bool 386 + { 387 + return true; 388 + } 389 + 390 + public function queue(): string 391 + { 392 + return 'high-volume'; 393 + } 394 + 395 + public function handle(SignalEvent $event): void 396 + { 397 + // Lightweight processing only 398 + $this->incrementCounter($event); 399 + } 400 + } 401 + ``` 402 + 403 + Run many workers: 404 + 405 + ```bash 406 + # 10 workers on high-volume queue 407 + php artisan queue:work --queue=high-volume --workers=10 408 + ``` 409 + 410 + ### Priority Queues 411 + 412 + Different priorities for different events: 413 + 414 + ```php 415 + class PrioritySignal extends Signal 416 + { 417 + public function shouldQueue(): bool 418 + { 419 + return true; 420 + } 421 + 422 + public function queue(): string 423 + { 424 + // Determine priority based on event 425 + return $this->getQueueForEvent(); 426 + } 427 + 428 + private function getQueueForEvent(): string 429 + { 430 + // Check event attributes 431 + // Return 'high', 'medium', or 'low' 432 + } 433 + } 434 + ``` 435 + 436 + Process high-priority first: 437 + 438 + ```bash 439 + php artisan queue:work --queue=high,medium,low 440 + ``` 441 + 442 + ### Delayed Processing 443 + 444 + Delay event processing: 445 + 446 + ```php 447 + public function handle(SignalEvent $event): void 448 + { 449 + // Dispatch with delay 450 + dispatch(function () use ($event) { 451 + $this->processLater($event); 452 + })->delay(now()->addMinutes(5)); 453 + } 454 + ``` 455 + 456 + ### Scheduled Batch Processing 457 + 458 + Collect events and process on schedule: 459 + 460 + ```php 461 + // Signal collects events 462 + class CollectorSignal extends Signal 463 + { 464 + public function handle(SignalEvent $event): void 465 + { 466 + PendingEvent::create([ 467 + 'data' => $event->toArray(), 468 + ]); 469 + } 470 + } 471 + 472 + // Scheduled command processes batch 473 + // app/Console/Kernel.php 474 + protected function schedule(Schedule $schedule) 475 + { 476 + $schedule->call(function () { 477 + $events = PendingEvent::all(); 478 + $this->processBatch($events); 479 + PendingEvent::truncate(); 480 + })->hourly(); 481 + } 482 + ``` 483 + 484 + ## Monitoring Queues 485 + 486 + ### Check Queue Status 487 + 488 + ```bash 489 + # View failed jobs 490 + php artisan queue:failed 491 + 492 + # Retry failed job 493 + php artisan queue:retry {id} 494 + 495 + # Retry all failed 496 + php artisan queue:retry all 497 + 498 + # Clear failed jobs 499 + php artisan queue:flush 500 + ``` 501 + 502 + ### Queue Metrics 503 + 504 + Track queue performance: 505 + 506 + ```php 507 + use Illuminate\Support\Facades\Queue; 508 + 509 + Queue::after(function ($connection, $job, $data) { 510 + Log::info('Job processed', [ 511 + 'queue' => $job->queue, 512 + 'class' => $job->resolveName(), 513 + 'attempts' => $job->attempts(), 514 + ]); 515 + }); 516 + ``` 517 + 518 + ### Horizon (Recommended) 519 + 520 + Use Laravel Horizon for Redis queues: 521 + 522 + ```bash 523 + composer require laravel/horizon 524 + php artisan horizon:install 525 + php artisan horizon 526 + ``` 527 + 528 + View dashboard at `/horizon`. 529 + 530 + ## Testing Queued Signals 531 + 532 + ### Test with Fake Queue 533 + 534 + ```php 535 + use Illuminate\Support\Facades\Queue; 536 + 537 + /** @test */ 538 + public function it_queues_events() 539 + { 540 + Queue::fake(); 541 + 542 + $signal = new MySignal(); 543 + $event = $this->createSampleEvent(); 544 + 545 + // Assert queue behavior 546 + $this->assertTrue($signal->shouldQueue()); 547 + 548 + // Process would normally queue 549 + $signal->handle($event); 550 + 551 + // Verify job was queued 552 + Queue::assertPushed(SignalJob::class); 553 + } 554 + ``` 555 + 556 + ### Test Synchronously 557 + 558 + Disable queueing for tests: 559 + 560 + ```php 561 + /** @test */ 562 + public function it_processes_events() 563 + { 564 + config(['queue.default' => 'sync']); 565 + 566 + $signal = new MySignal(); 567 + $event = $this->createSampleEvent(); 568 + 569 + $signal->handle($event); 570 + 571 + // Assert processing happened 572 + $this->assertDatabaseHas('posts', [...]); 573 + } 574 + ``` 575 + 576 + [Learn more about testing →](testing.md) 577 + 578 + ## Production Checklist 579 + 580 + ### Infrastructure 581 + 582 + - [ ] Queue driver configured (Redis recommended) 583 + - [ ] Supervisor installed and configured 584 + - [ ] Multiple workers running 585 + - [ ] Worker auto-restart enabled 586 + - [ ] Logs configured and monitored 587 + 588 + ### Configuration 589 + 590 + - [ ] Queue connection set correctly 591 + - [ ] Queue names configured 592 + - [ ] Retry attempts configured 593 + - [ ] Timeout values appropriate 594 + - [ ] Memory limits set 595 + 596 + ### Monitoring 597 + 598 + - [ ] Queue length monitored 599 + - [ ] Failed jobs tracked 600 + - [ ] Worker health checked 601 + - [ ] Processing times measured 602 + - [ ] Horizon installed (if using Redis) 603 + 604 + ### Scaling 605 + 606 + - [ ] Worker count appropriate for volume 607 + - [ ] Priority queues configured 608 + - [ ] Rate limiting implemented 609 + - [ ] Database connection pooling enabled 610 + - [ ] Redis maxmemory policy set 611 + 612 + ## Common Issues 613 + 614 + ### Queue Jobs Not Processing 615 + 616 + **Check worker is running:** 617 + ```bash 618 + php artisan queue:work 619 + ``` 620 + 621 + **Check queue connection:** 622 + ```php 623 + // Should match QUEUE_CONNECTION 624 + config('queue.default') 625 + ``` 626 + 627 + ### Jobs Timing Out 628 + 629 + **Increase timeout:** 630 + ```bash 631 + php artisan queue:work --timeout=300 632 + ``` 633 + 634 + **Or in Signal:** 635 + ```php 636 + public function timeout(): int 637 + { 638 + return 300; // 5 minutes 639 + } 640 + ``` 641 + 642 + ### Memory Leaks 643 + 644 + **Restart workers periodically:** 645 + ```bash 646 + php artisan queue:work --max-jobs=1000 647 + ``` 648 + 649 + Or: 650 + ```bash 651 + php artisan queue:work --max-time=3600 652 + ``` 653 + 654 + ### Failed Jobs Piling Up 655 + 656 + **Review failures:** 657 + ```bash 658 + php artisan queue:failed 659 + ``` 660 + 661 + **Retry or delete:** 662 + ```bash 663 + php artisan queue:retry all 664 + # or 665 + php artisan queue:flush 666 + ``` 667 + 668 + ## Next Steps 669 + 670 + - **[Review configuration options →](configuration.md)** - Fine-tune queue settings 671 + - **[Learn about testing →](testing.md)** - Test queued Signals 672 + - **[See real-world examples →](examples.md)** - Learn from production queue patterns
+391
docs/quickstart.md
··· 1 + # Quickstart Guide 2 + 3 + This guide will walk you through building your first Signal and consuming AT Protocol events in under 5 minutes. 4 + 5 + ## Prerequisites 6 + 7 + Before starting, ensure you have: 8 + 9 + - [Installed Signal](installation.md) in your Laravel application 10 + - Run `php artisan signal:install` successfully 11 + - Basic familiarity with Laravel 12 + 13 + ## Your First Signal 14 + 15 + We'll build a Signal that logs every new post created on Bluesky. 16 + 17 + ### Step 1: Generate a Signal 18 + 19 + Use the Artisan command to create a new Signal: 20 + 21 + ```bash 22 + php artisan make:signal NewPostSignal 23 + ``` 24 + 25 + This creates `app/Signals/NewPostSignal.php` with a basic template. 26 + 27 + ### Step 2: Define the Signal 28 + 29 + Open the generated file and update it: 30 + 31 + ```php 32 + <?php 33 + 34 + namespace App\Signals; 35 + 36 + use SocialDept\Signal\Events\SignalEvent; 37 + use SocialDept\Signal\Signals\Signal; 38 + use Illuminate\Support\Facades\Log; 39 + 40 + class NewPostSignal extends Signal 41 + { 42 + /** 43 + * Define which event types to listen for. 44 + */ 45 + public function eventTypes(): array 46 + { 47 + return ['commit']; // Listen for repository commits 48 + } 49 + 50 + /** 51 + * Filter by specific collections. 52 + */ 53 + public function collections(): ?array 54 + { 55 + return ['app.bsky.feed.post']; // Only handle posts 56 + } 57 + 58 + /** 59 + * Handle the event when it arrives. 60 + */ 61 + public function handle(SignalEvent $event): void 62 + { 63 + $record = $event->getRecord(); 64 + 65 + Log::info('New post created', [ 66 + 'author' => $event->did, 67 + 'text' => $record->text ?? null, 68 + 'created_at' => $record->createdAt ?? null, 69 + ]); 70 + } 71 + } 72 + ``` 73 + 74 + ### Step 3: Start Consuming Events 75 + 76 + Run the consumer to start listening: 77 + 78 + ```bash 79 + php artisan signal:consume 80 + ``` 81 + 82 + You should see output like: 83 + 84 + ``` 85 + Starting Signal consumer in jetstream mode... 86 + Connecting to wss://jetstream2.us-east.bsky.network... 87 + Connected! Listening for events... 88 + ``` 89 + 90 + **Congratulations!** Your Signal is now processing every new post on Bluesky in real-time. Check your Laravel logs to see the posts coming in. 91 + 92 + ## Understanding What Just Happened 93 + 94 + Let's break down the Signal you created: 95 + 96 + ### Event Types 97 + 98 + ```php 99 + public function eventTypes(): array 100 + { 101 + return ['commit']; 102 + } 103 + ``` 104 + 105 + This tells Signal you want **commit** events, which represent changes to repositories (like creating posts, likes, follows, etc.). 106 + 107 + Available event types: 108 + - `commit` - Repository commits (most common) 109 + - `identity` - Identity changes (handle updates) 110 + - `account` - Account status changes 111 + 112 + ### Collections 113 + 114 + ```php 115 + public function collections(): ?array 116 + { 117 + return ['app.bsky.feed.post']; 118 + } 119 + ``` 120 + 121 + This filters to only **post** collections. Without this filter, your Signal would receive all commit events for every collection type. 122 + 123 + Common collections: 124 + - `app.bsky.feed.post` - Posts 125 + - `app.bsky.feed.like` - Likes 126 + - `app.bsky.graph.follow` - Follows 127 + - `app.bsky.feed.repost` - Reposts 128 + 129 + [Learn more about filtering →](filtering.md) 130 + 131 + ### Handler Method 132 + 133 + ```php 134 + public function handle(SignalEvent $event): void 135 + { 136 + $record = $event->getRecord(); 137 + // Your logic here 138 + } 139 + ``` 140 + 141 + This is where your code runs for each matching event. The `$event` object contains: 142 + 143 + - `did` - The user's DID (decentralized identifier) 144 + - `timeUs` - Timestamp in microseconds 145 + - `commit` - Commit details (collection, operation, record key) 146 + - `getRecord()` - The actual record data 147 + 148 + ## Next Steps 149 + 150 + Now that you've built your first Signal, let's make it more useful. 151 + 152 + ### Add More Filtering 153 + 154 + Track specific operations only: 155 + 156 + ```php 157 + use SocialDept\Signal\Enums\SignalCommitOperation; 158 + 159 + public function operations(): ?array 160 + { 161 + return [SignalCommitOperation::Create]; // Only new posts, not edits 162 + } 163 + ``` 164 + 165 + [Learn more about filtering →](filtering.md) 166 + 167 + ### Process Events Asynchronously 168 + 169 + For expensive operations, use Laravel queues: 170 + 171 + ```php 172 + public function shouldQueue(): bool 173 + { 174 + return true; 175 + } 176 + 177 + public function handle(SignalEvent $event): void 178 + { 179 + // This now runs in a background job 180 + $this->performExpensiveAnalysis($event); 181 + } 182 + ``` 183 + 184 + [Learn more about queues →](queues.md) 185 + 186 + ### Store Data 187 + 188 + Let's store posts in your database: 189 + 190 + ```php 191 + use App\Models\Post; 192 + 193 + public function handle(SignalEvent $event): void 194 + { 195 + $record = $event->getRecord(); 196 + 197 + Post::updateOrCreate( 198 + [ 199 + 'did' => $event->did, 200 + 'rkey' => $event->commit->rkey, 201 + ], 202 + [ 203 + 'text' => $record->text ?? null, 204 + 'created_at' => $record->createdAt, 205 + ] 206 + ); 207 + } 208 + ``` 209 + 210 + ### Handle Multiple Collections 211 + 212 + Use wildcards to match multiple collections: 213 + 214 + ```php 215 + public function collections(): ?array 216 + { 217 + return [ 218 + 'app.bsky.feed.*', // All feed events 219 + ]; 220 + } 221 + 222 + public function handle(SignalEvent $event): void 223 + { 224 + $collection = $event->getCollection(); 225 + 226 + match ($collection) { 227 + 'app.bsky.feed.post' => $this->handlePost($event), 228 + 'app.bsky.feed.like' => $this->handleLike($event), 229 + 'app.bsky.feed.repost' => $this->handleRepost($event), 230 + default => null, 231 + }; 232 + } 233 + ``` 234 + 235 + ## Building Something Real 236 + 237 + Let's build a simple engagement tracker: 238 + 239 + ```php 240 + <?php 241 + 242 + namespace App\Signals; 243 + 244 + use App\Models\EngagementMetric; 245 + use SocialDept\Signal\Enums\SignalCommitOperation; 246 + use SocialDept\Signal\Events\SignalEvent; 247 + use SocialDept\Signal\Signals\Signal; 248 + 249 + class EngagementTrackerSignal extends Signal 250 + { 251 + public function eventTypes(): array 252 + { 253 + return ['commit']; 254 + } 255 + 256 + public function collections(): ?array 257 + { 258 + return [ 259 + 'app.bsky.feed.post', 260 + 'app.bsky.feed.like', 261 + 'app.bsky.feed.repost', 262 + ]; 263 + } 264 + 265 + public function operations(): ?array 266 + { 267 + return [SignalCommitOperation::Create]; 268 + } 269 + 270 + public function shouldQueue(): bool 271 + { 272 + return true; // Process in background 273 + } 274 + 275 + public function handle(SignalEvent $event): void 276 + { 277 + EngagementMetric::create([ 278 + 'date' => now()->toDateString(), 279 + 'collection' => $event->getCollection(), 280 + 'event_type' => 'create', 281 + 'count' => 1, 282 + ]); 283 + } 284 + } 285 + ``` 286 + 287 + This Signal tracks all engagement activity (posts, likes, reposts) and stores metrics for analysis. 288 + 289 + ## Testing Your Signal 290 + 291 + Before running in production, test your Signal with sample data: 292 + 293 + ```bash 294 + php artisan signal:test NewPostSignal 295 + ``` 296 + 297 + This will run your Signal with a sample event and show you the output. 298 + 299 + [Learn more about testing →](testing.md) 300 + 301 + ## Common Patterns 302 + 303 + ### Only Process Specific Users 304 + 305 + ```php 306 + public function dids(): ?array 307 + { 308 + return [ 309 + 'did:plc:z72i7hdynmk6r22z27h6tvur', // Specific user 310 + ]; 311 + } 312 + ``` 313 + 314 + ### Add Custom Filtering Logic 315 + 316 + ```php 317 + public function shouldHandle(SignalEvent $event): bool 318 + { 319 + $record = $event->getRecord(); 320 + 321 + // Only handle posts with images 322 + return isset($record->embed); 323 + } 324 + ``` 325 + 326 + ### Handle Failures Gracefully 327 + 328 + ```php 329 + public function failed(SignalEvent $event, \Throwable $exception): void 330 + { 331 + Log::error('Signal processing failed', [ 332 + 'event' => $event->toArray(), 333 + 'error' => $exception->getMessage(), 334 + ]); 335 + 336 + // Optionally notify admins, store for retry, etc. 337 + } 338 + ``` 339 + 340 + ## Running in Production 341 + 342 + ### Using Supervisor 343 + 344 + For production, run Signal under a process monitor like Supervisor: 345 + 346 + ```ini 347 + [program:signal-consumer] 348 + process_name=%(program_name)s 349 + command=php /path/to/artisan signal:consume 350 + autostart=true 351 + autorestart=true 352 + user=www-data 353 + redirect_stderr=true 354 + stdout_logfile=/path/to/logs/signal-consumer.log 355 + ``` 356 + 357 + ### Starting from Last Position 358 + 359 + Signal automatically saves cursor positions, so it resumes from where it left off: 360 + 361 + ```bash 362 + php artisan signal:consume 363 + ``` 364 + 365 + To start fresh and ignore stored position: 366 + 367 + ```bash 368 + php artisan signal:consume --fresh 369 + ``` 370 + 371 + To start from a specific cursor: 372 + 373 + ```bash 374 + php artisan signal:consume --cursor=123456789 375 + ``` 376 + 377 + ## What's Next? 378 + 379 + You now know the basics of building Signals! Explore more advanced topics: 380 + 381 + - **[Signal Architecture](signals.md)** - Deep dive into Signal structure 382 + - **[Advanced Filtering](filtering.md)** - Master collection patterns and wildcards 383 + - **[Jetstream vs Firehose](modes.md)** - Choose the right mode for your use case 384 + - **[Queue Integration](queues.md)** - Build high-performance processors 385 + - **[Real-World Examples](examples.md)** - Learn from production use cases 386 + 387 + ## Getting Help 388 + 389 + - Check the [examples documentation](examples.md) for more patterns 390 + - Review the [configuration guide](configuration.md) for all options 391 + - Open an issue on GitHub if you encounter problems
+702
docs/signals.md
··· 1 + # Creating Signals 2 + 3 + Signals are the heart of the Signal package. They define how your application responds to AT Protocol events. 4 + 5 + ## What is a Signal? 6 + 7 + A **Signal** is a PHP class that: 8 + 9 + 1. Listens for specific types of AT Protocol events 10 + 2. Filters those events based on your criteria 11 + 3. Executes custom logic when matching events arrive 12 + 13 + Think of Signals like Laravel event listeners, but specifically designed for the AT Protocol. 14 + 15 + ## Basic Signal Structure 16 + 17 + Every Signal extends the base `Signal` class: 18 + 19 + ```php 20 + <?php 21 + 22 + namespace App\Signals; 23 + 24 + use SocialDept\Signal\Events\SignalEvent; 25 + use SocialDept\Signal\Signals\Signal; 26 + 27 + class MySignal extends Signal 28 + { 29 + /** 30 + * Define which event types to listen for. 31 + * Required. 32 + */ 33 + public function eventTypes(): array 34 + { 35 + return ['commit']; 36 + } 37 + 38 + /** 39 + * Handle the event when it arrives. 40 + * Required. 41 + */ 42 + public function handle(SignalEvent $event): void 43 + { 44 + // Your logic here 45 + } 46 + } 47 + ``` 48 + 49 + Only two methods are required: 50 + - `eventTypes()` - Which event types to listen for 51 + - `handle()` - What to do when events arrive 52 + 53 + ## Creating Signals 54 + 55 + ### Using Artisan (Recommended) 56 + 57 + Generate a new Signal with the make command: 58 + 59 + ```bash 60 + php artisan make:signal MySignal 61 + ``` 62 + 63 + This creates `app/Signals/MySignal.php` with a basic template. 64 + 65 + #### With Options 66 + 67 + Generate a Signal with pre-configured filters: 68 + 69 + ```bash 70 + # Create a Signal for posts only 71 + php artisan make:signal PostSignal --type=commit --collection=app.bsky.feed.post 72 + 73 + # Create a Signal for follows 74 + php artisan make:signal FollowSignal --type=commit --collection=app.bsky.graph.follow 75 + ``` 76 + 77 + ### Manual Creation 78 + 79 + You can also create Signals manually in `app/Signals/`: 80 + 81 + ```php 82 + <?php 83 + 84 + namespace App\Signals; 85 + 86 + use SocialDept\Signal\Events\SignalEvent; 87 + use SocialDept\Signal\Signals\Signal; 88 + 89 + class ManualSignal extends Signal 90 + { 91 + public function eventTypes(): array 92 + { 93 + return ['commit']; 94 + } 95 + 96 + public function handle(SignalEvent $event): void 97 + { 98 + // 99 + } 100 + } 101 + ``` 102 + 103 + Signals are automatically discovered from `app/Signals/` - no registration needed. 104 + 105 + ## Event Types 106 + 107 + Signals can listen for three types of AT Protocol events: 108 + 109 + ### Commit Events 110 + 111 + Repository commits represent changes to user data: 112 + 113 + ```php 114 + use SocialDept\Signal\Enums\SignalEventType; 115 + 116 + public function eventTypes(): array 117 + { 118 + return [SignalEventType::Commit]; 119 + // Or: return ['commit']; 120 + } 121 + ``` 122 + 123 + **Common commit events:** 124 + - Creating posts, likes, follows, reposts 125 + - Updating profile information 126 + - Deleting content 127 + 128 + This is the most common event type and what you'll use 99% of the time. 129 + 130 + ### Identity Events 131 + 132 + Identity changes track handle updates: 133 + 134 + ```php 135 + public function eventTypes(): array 136 + { 137 + return [SignalEventType::Identity]; 138 + // Or: return ['identity']; 139 + } 140 + ``` 141 + 142 + **Use cases:** 143 + - Tracking handle changes 144 + - Updating local user records 145 + - Monitoring account migrations 146 + 147 + ### Account Events 148 + 149 + Account status changes track account state: 150 + 151 + ```php 152 + public function eventTypes(): array 153 + { 154 + return [SignalEventType::Account]; 155 + // Or: return ['account']; 156 + } 157 + ``` 158 + 159 + **Use cases:** 160 + - Detecting account deactivation 161 + - Monitoring account status 162 + - Compliance tracking 163 + 164 + ### Multiple Event Types 165 + 166 + Listen to multiple event types in one Signal: 167 + 168 + ```php 169 + public function eventTypes(): array 170 + { 171 + return [ 172 + SignalEventType::Commit, 173 + SignalEventType::Identity, 174 + ]; 175 + } 176 + 177 + public function handle(SignalEvent $event): void 178 + { 179 + if ($event->isCommit()) { 180 + // Handle commit 181 + } 182 + 183 + if ($event->isIdentity()) { 184 + // Handle identity change 185 + } 186 + } 187 + ``` 188 + 189 + ## The SignalEvent Object 190 + 191 + The `SignalEvent` object contains all event data: 192 + 193 + ### Common Properties 194 + 195 + ```php 196 + public function handle(SignalEvent $event): void 197 + { 198 + // User's DID (decentralized identifier) 199 + $did = $event->did; // "did:plc:z72i7hdynmk6r22z27h6tvur" 200 + 201 + // Event type (commit, identity, account) 202 + $kind = $event->kind; 203 + 204 + // Timestamp in microseconds 205 + $timestamp = $event->timeUs; 206 + 207 + // Convert to Carbon instance 208 + $date = $event->getTimestamp(); 209 + } 210 + ``` 211 + 212 + ### Commit Events 213 + 214 + For commit events, access the `commit` property: 215 + 216 + ```php 217 + public function handle(SignalEvent $event): void 218 + { 219 + if ($event->isCommit()) { 220 + // Collection (e.g., "app.bsky.feed.post") 221 + $collection = $event->commit->collection; 222 + // Or: $collection = $event->getCollection(); 223 + 224 + // Operation (create, update, delete) 225 + $operation = $event->commit->operation; 226 + // Or: $operation = $event->getOperation(); 227 + 228 + // Record key (unique identifier) 229 + $rkey = $event->commit->rkey; 230 + 231 + // Revision 232 + $rev = $event->commit->rev; 233 + 234 + // The actual record data 235 + $record = $event->commit->record; 236 + // Or: $record = $event->getRecord(); 237 + } 238 + } 239 + ``` 240 + 241 + ### Working with Records 242 + 243 + Records contain the actual data (posts, likes, etc.): 244 + 245 + ```php 246 + public function handle(SignalEvent $event): void 247 + { 248 + $record = $event->getRecord(); 249 + 250 + // For posts (app.bsky.feed.post) 251 + $text = $record->text ?? null; 252 + $createdAt = $record->createdAt ?? null; 253 + $embed = $record->embed ?? null; 254 + $facets = $record->facets ?? null; 255 + 256 + // For likes (app.bsky.feed.like) 257 + $subject = $record->subject ?? null; 258 + 259 + // For follows (app.bsky.graph.follow) 260 + $subject = $record->subject ?? null; 261 + } 262 + ``` 263 + 264 + Records are `stdClass` objects, so use null coalescing (`??`) for safety. 265 + 266 + ### Identity Events 267 + 268 + For identity events, access the `identity` property: 269 + 270 + ```php 271 + public function handle(SignalEvent $event): void 272 + { 273 + if ($event->isIdentity()) { 274 + // New handle 275 + $handle = $event->identity->handle; 276 + 277 + // User's DID 278 + $did = $event->did; 279 + 280 + // Sequence number 281 + $seq = $event->identity->seq; 282 + 283 + // Timestamp 284 + $time = $event->identity->time; 285 + } 286 + } 287 + ``` 288 + 289 + ### Account Events 290 + 291 + For account events, access the `account` property: 292 + 293 + ```php 294 + public function handle(SignalEvent $event): void 295 + { 296 + if ($event->isAccount()) { 297 + // Account status 298 + $active = $event->account->active; // true/false 299 + 300 + // Status reason 301 + $status = $event->account->status ?? null; 302 + 303 + // User's DID 304 + $did = $event->did; 305 + 306 + // Sequence number 307 + $seq = $event->account->seq; 308 + 309 + // Timestamp 310 + $time = $event->account->time; 311 + } 312 + } 313 + ``` 314 + 315 + ## Helper Methods 316 + 317 + Signals provide several helper methods for common tasks: 318 + 319 + ### Type Checking 320 + 321 + ```php 322 + public function handle(SignalEvent $event): void 323 + { 324 + // Check event type 325 + if ($event->isCommit()) { 326 + // 327 + } 328 + 329 + if ($event->isIdentity()) { 330 + // 331 + } 332 + 333 + if ($event->isAccount()) { 334 + // 335 + } 336 + } 337 + ``` 338 + 339 + ### Operation Checking (Commit Events) 340 + 341 + ```php 342 + use SocialDept\Signal\Enums\SignalCommitOperation; 343 + 344 + public function handle(SignalEvent $event): void 345 + { 346 + $operation = $event->getOperation(); 347 + 348 + // Using enum 349 + if ($operation === SignalCommitOperation::Create) { 350 + // Handle new records 351 + } 352 + 353 + // Using commit helper 354 + if ($event->commit->isCreate()) { 355 + // Handle new records 356 + } 357 + 358 + if ($event->commit->isUpdate()) { 359 + // Handle updates 360 + } 361 + 362 + if ($event->commit->isDelete()) { 363 + // Handle deletions 364 + } 365 + } 366 + ``` 367 + 368 + ### Data Extraction 369 + 370 + ```php 371 + public function handle(SignalEvent $event): void 372 + { 373 + // Get collection (commit events only) 374 + $collection = $event->getCollection(); 375 + 376 + // Get operation (commit events only) 377 + $operation = $event->getOperation(); 378 + 379 + // Get record (commit events only) 380 + $record = $event->getRecord(); 381 + 382 + // Get timestamp as Carbon 383 + $timestamp = $event->getTimestamp(); 384 + 385 + // Convert to array 386 + $array = $event->toArray(); 387 + } 388 + ``` 389 + 390 + ## Optional Signal Methods 391 + 392 + Signals support several optional methods for advanced behavior: 393 + 394 + ### Collections Filter 395 + 396 + Filter by AT Protocol collections: 397 + 398 + ```php 399 + public function collections(): ?array 400 + { 401 + return ['app.bsky.feed.post']; 402 + } 403 + ``` 404 + 405 + Return `null` to handle all collections. 406 + 407 + [Learn more about collection filtering →](filtering.md) 408 + 409 + ### Operations Filter 410 + 411 + Filter by operation type (commit events only): 412 + 413 + ```php 414 + public function operations(): ?array 415 + { 416 + return [SignalCommitOperation::Create]; 417 + } 418 + ``` 419 + 420 + Return `null` to handle all operations. 421 + 422 + [Learn more about operation filtering →](filtering.md) 423 + 424 + ### DIDs Filter 425 + 426 + Filter by specific users: 427 + 428 + ```php 429 + public function dids(): ?array 430 + { 431 + return [ 432 + 'did:plc:z72i7hdynmk6r22z27h6tvur', 433 + ]; 434 + } 435 + ``` 436 + 437 + Return `null` to handle all users. 438 + 439 + [Learn more about DID filtering →](filtering.md) 440 + 441 + ### Custom Filtering 442 + 443 + Add complex filtering logic: 444 + 445 + ```php 446 + public function shouldHandle(SignalEvent $event): bool 447 + { 448 + // Only handle posts with images 449 + if ($event->isCommit() && $event->getCollection() === 'app.bsky.feed.post') { 450 + $record = $event->getRecord(); 451 + return isset($record->embed); 452 + } 453 + 454 + return true; 455 + } 456 + ``` 457 + 458 + ### Queue Configuration 459 + 460 + Process events asynchronously: 461 + 462 + ```php 463 + public function shouldQueue(): bool 464 + { 465 + return true; 466 + } 467 + 468 + public function queue(): string 469 + { 470 + return 'high-priority'; 471 + } 472 + 473 + public function queueConnection(): string 474 + { 475 + return 'redis'; 476 + } 477 + ``` 478 + 479 + [Learn more about queue integration →](queues.md) 480 + 481 + ### Failure Handling 482 + 483 + Handle processing failures: 484 + 485 + ```php 486 + public function failed(SignalEvent $event, \Throwable $exception): void 487 + { 488 + Log::error('Signal failed', [ 489 + 'signal' => static::class, 490 + 'event' => $event->toArray(), 491 + 'error' => $exception->getMessage(), 492 + ]); 493 + } 494 + ``` 495 + 496 + ## Signal Lifecycle 497 + 498 + Understanding the Signal lifecycle helps you write better Signals: 499 + 500 + ### 1. Event Arrives 501 + 502 + An event arrives from the AT Protocol (via Jetstream or Firehose). 503 + 504 + ### 2. Event Type Matching 505 + 506 + Signal checks if the event type matches your `eventTypes()` definition. 507 + 508 + ### 3. Collection Filtering 509 + 510 + If defined, Signal checks if the collection matches your `collections()` definition. 511 + 512 + ### 4. Operation Filtering 513 + 514 + If defined, Signal checks if the operation matches your `operations()` definition. 515 + 516 + ### 5. DID Filtering 517 + 518 + If defined, Signal checks if the DID matches your `dids()` definition. 519 + 520 + ### 6. Custom Filtering 521 + 522 + If defined, Signal calls your `shouldHandle()` method. 523 + 524 + ### 7. Queue Decision 525 + 526 + Signal checks `shouldQueue()` to determine if the event should be queued. 527 + 528 + ### 8. Handler Execution 529 + 530 + Your `handle()` method is called (either synchronously or via queue). 531 + 532 + ### 9. Failure Handling (if applicable) 533 + 534 + If an exception occurs, your `failed()` method is called (if defined). 535 + 536 + ## Best Practices 537 + 538 + ### Keep Handlers Focused 539 + 540 + Each Signal should do one thing well: 541 + 542 + ```php 543 + // Good - focused on one task 544 + class TrackNewPostsSignal extends Signal 545 + { 546 + public function collections(): ?array 547 + { 548 + return ['app.bsky.feed.post']; 549 + } 550 + 551 + public function handle(SignalEvent $event): void 552 + { 553 + $this->storePost($event); 554 + } 555 + } 556 + 557 + // Less ideal - doing too much 558 + class MonitorEverythingSignal extends Signal 559 + { 560 + public function handle(SignalEvent $event): void 561 + { 562 + $this->storePost($event); 563 + $this->sendNotification($event); 564 + $this->updateAnalytics($event); 565 + $this->processRecommendations($event); 566 + } 567 + } 568 + ``` 569 + 570 + ### Use Queues for Heavy Work 571 + 572 + Don't block the consumer with expensive operations: 573 + 574 + ```php 575 + class AnalyzePostSignal extends Signal 576 + { 577 + public function shouldQueue(): bool 578 + { 579 + return true; // Process in background 580 + } 581 + 582 + public function handle(SignalEvent $event): void 583 + { 584 + $this->performExpensiveAnalysis($event); 585 + } 586 + } 587 + ``` 588 + 589 + ### Validate Data Safely 590 + 591 + Records can have missing or unexpected data: 592 + 593 + ```php 594 + public function handle(SignalEvent $event): void 595 + { 596 + $record = $event->getRecord(); 597 + 598 + // Use null coalescing 599 + $text = $record->text ?? ''; 600 + 601 + // Validate before processing 602 + if (empty($text)) { 603 + return; 604 + } 605 + 606 + // Safe to process 607 + $this->processText($text); 608 + } 609 + ``` 610 + 611 + ### Add Logging 612 + 613 + Log important events for debugging: 614 + 615 + ```php 616 + public function handle(SignalEvent $event): void 617 + { 618 + Log::debug('Processing event', [ 619 + 'signal' => static::class, 620 + 'collection' => $event->getCollection(), 621 + 'operation' => $event->getOperation()->value, 622 + ]); 623 + 624 + // Your logic 625 + } 626 + ``` 627 + 628 + ### Handle Failures Gracefully 629 + 630 + Always implement failure handling for queued Signals: 631 + 632 + ```php 633 + public function failed(SignalEvent $event, \Throwable $exception): void 634 + { 635 + Log::error('Signal processing failed', [ 636 + 'signal' => static::class, 637 + 'event_did' => $event->did, 638 + 'error' => $exception->getMessage(), 639 + 'trace' => $exception->getTraceAsString(), 640 + ]); 641 + 642 + // Optionally: send to error tracking service 643 + // report($exception); 644 + } 645 + ``` 646 + 647 + ## Auto-Discovery 648 + 649 + Signals are automatically discovered from `app/Signals/` by default. You can customize discovery in `config/signal.php`: 650 + 651 + ```php 652 + 'auto_discovery' => [ 653 + 'enabled' => true, 654 + 'path' => app_path('Signals'), 655 + 'namespace' => 'App\\Signals', 656 + ], 657 + ``` 658 + 659 + ### Manual Registration 660 + 661 + Disable auto-discovery and register Signals manually: 662 + 663 + ```php 664 + 'auto_discovery' => [ 665 + 'enabled' => false, 666 + ], 667 + 668 + 'signals' => [ 669 + \App\Signals\NewPostSignal::class, 670 + \App\Signals\NewFollowSignal::class, 671 + ], 672 + ``` 673 + 674 + ## Testing Signals 675 + 676 + Test your Signals before deploying: 677 + 678 + ```bash 679 + php artisan signal:test MySignal 680 + ``` 681 + 682 + [Learn more about testing →](testing.md) 683 + 684 + ## Listing Signals 685 + 686 + View all registered Signals: 687 + 688 + ```bash 689 + php artisan signal:list 690 + ``` 691 + 692 + This displays: 693 + - Signal class names 694 + - Event types they listen for 695 + - Collection filters (if any) 696 + - Queue configuration 697 + 698 + ## Next Steps 699 + 700 + - **[Learn about filtering →](filtering.md)** - Master collection patterns and wildcards 701 + - **[Understand queue integration →](queues.md)** - Build high-performance processors 702 + - **[See real-world examples →](examples.md)** - Learn from production use cases
+728
docs/testing.md
··· 1 + # Testing Signals 2 + 3 + Testing your Signals ensures they behave correctly before deploying to production. Signal provides tools for both manual and automated testing. 4 + 5 + ## Quick Testing with Artisan 6 + 7 + The fastest way to test a Signal is with the `signal:test` command. 8 + 9 + ### Test a Signal 10 + 11 + ```bash 12 + php artisan signal:test NewPostSignal 13 + ``` 14 + 15 + This runs your Signal with sample event data and displays the output. 16 + 17 + ### What It Does 18 + 19 + 1. Creates a sample `SignalEvent` matching your Signal's filters 20 + 2. Calls your Signal's `handle()` method 21 + 3. Displays output, logs, and any errors 22 + 4. Shows execution time 23 + 24 + ### Example Output 25 + 26 + ``` 27 + Testing Signal: App\Signals\NewPostSignal 28 + 29 + Creating sample commit event for collection: app.bsky.feed.post 30 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 31 + 32 + Event Details: 33 + DID: did:plc:test123 34 + Collection: app.bsky.feed.post 35 + Operation: create 36 + Text: Sample post for testing 37 + 38 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 39 + 40 + Processing event... 41 + ✓ Signal processed successfully 42 + 43 + Execution time: 12ms 44 + ``` 45 + 46 + ### Limitations 47 + 48 + - Uses sample data (not real events) 49 + - Doesn't test filtering logic comprehensively 50 + - Can't test queue behavior 51 + - Limited to basic scenarios 52 + 53 + For comprehensive testing, write automated tests. 54 + 55 + ## Unit Testing 56 + 57 + Test your Signals in isolation. 58 + 59 + ### Basic Test Structure 60 + 61 + ```php 62 + <?php 63 + 64 + namespace Tests\Unit\Signals; 65 + 66 + use App\Signals\NewPostSignal; 67 + use SocialDept\Signal\Events\CommitEvent; 68 + use SocialDept\Signal\Events\SignalEvent; 69 + use Tests\TestCase; 70 + 71 + class NewPostSignalTest extends TestCase 72 + { 73 + /** @test */ 74 + public function it_handles_new_posts() 75 + { 76 + $signal = new NewPostSignal(); 77 + 78 + $event = new SignalEvent( 79 + did: 'did:plc:test123', 80 + timeUs: time() * 1000000, 81 + kind: 'commit', 82 + commit: new CommitEvent( 83 + rev: 'test', 84 + operation: 'create', 85 + collection: 'app.bsky.feed.post', 86 + rkey: 'test123', 87 + record: (object) [ 88 + 'text' => 'Hello World!', 89 + 'createdAt' => now()->toIso8601String(), 90 + ], 91 + ), 92 + ); 93 + 94 + $signal->handle($event); 95 + 96 + // Assert expected behavior 97 + $this->assertDatabaseHas('posts', [ 98 + 'text' => 'Hello World!', 99 + ]); 100 + } 101 + } 102 + ``` 103 + 104 + ### Testing Event Types 105 + 106 + Verify your Signal listens for correct event types: 107 + 108 + ```php 109 + /** @test */ 110 + public function it_listens_for_commit_events() 111 + { 112 + $signal = new NewPostSignal(); 113 + 114 + $eventTypes = $signal->eventTypes(); 115 + 116 + $this->assertContains('commit', $eventTypes); 117 + } 118 + ``` 119 + 120 + ### Testing Filters 121 + 122 + Verify collection filtering: 123 + 124 + ```php 125 + /** @test */ 126 + public function it_filters_to_posts_only() 127 + { 128 + $signal = new NewPostSignal(); 129 + 130 + $collections = $signal->collections(); 131 + 132 + $this->assertEquals(['app.bsky.feed.post'], $collections); 133 + } 134 + ``` 135 + 136 + ### Testing Operation Filtering 137 + 138 + ```php 139 + /** @test */ 140 + public function it_only_handles_creates() 141 + { 142 + $signal = new NewPostSignal(); 143 + 144 + $operations = $signal->operations(); 145 + 146 + $this->assertEquals([SignalCommitOperation::Create], $operations); 147 + } 148 + ``` 149 + 150 + ### Testing Custom Filtering 151 + 152 + ```php 153 + /** @test */ 154 + public function it_filters_posts_with_images() 155 + { 156 + $signal = new ImagePostSignal(); 157 + 158 + // Event with image 159 + $eventWithImage = $this->createEvent([ 160 + 'text' => 'Check this out!', 161 + 'embed' => (object) ['type' => 'image'], 162 + ]); 163 + 164 + $this->assertTrue($signal->shouldHandle($eventWithImage)); 165 + 166 + // Event without image 167 + $eventWithoutImage = $this->createEvent([ 168 + 'text' => 'Just text', 169 + ]); 170 + 171 + $this->assertFalse($signal->shouldHandle($eventWithoutImage)); 172 + } 173 + ``` 174 + 175 + ## Feature Testing 176 + 177 + Test Signals in the context of your application. 178 + 179 + ### Test with Database 180 + 181 + ```php 182 + <?php 183 + 184 + namespace Tests\Feature\Signals; 185 + 186 + use App\Models\Post; 187 + use App\Signals\StorePostSignal; 188 + use Illuminate\Foundation\Testing\RefreshDatabase; 189 + use SocialDept\Signal\Events\CommitEvent; 190 + use SocialDept\Signal\Events\SignalEvent; 191 + use Tests\TestCase; 192 + 193 + class StorePostSignalTest extends TestCase 194 + { 195 + use RefreshDatabase; 196 + 197 + /** @test */ 198 + public function it_stores_posts_in_database() 199 + { 200 + $signal = new StorePostSignal(); 201 + 202 + $event = new SignalEvent( 203 + did: 'did:plc:test123', 204 + timeUs: time() * 1000000, 205 + kind: 'commit', 206 + commit: new CommitEvent( 207 + rev: 'abc', 208 + operation: 'create', 209 + collection: 'app.bsky.feed.post', 210 + rkey: 'test', 211 + record: (object) [ 212 + 'text' => 'Test post', 213 + 'createdAt' => now()->toIso8601String(), 214 + ], 215 + ), 216 + ); 217 + 218 + $signal->handle($event); 219 + 220 + $this->assertDatabaseHas('posts', [ 221 + 'did' => 'did:plc:test123', 222 + 'text' => 'Test post', 223 + ]); 224 + } 225 + 226 + /** @test */ 227 + public function it_updates_existing_posts() 228 + { 229 + Post::create([ 230 + 'did' => 'did:plc:test123', 231 + 'rkey' => 'test', 232 + 'text' => 'Old text', 233 + ]); 234 + 235 + $signal = new StorePostSignal(); 236 + 237 + $event = $this->createUpdateEvent([ 238 + 'text' => 'New text', 239 + ]); 240 + 241 + $signal->handle($event); 242 + 243 + $this->assertDatabaseHas('posts', [ 244 + 'did' => 'did:plc:test123', 245 + 'text' => 'New text', 246 + ]); 247 + 248 + $this->assertEquals(1, Post::count()); 249 + } 250 + 251 + /** @test */ 252 + public function it_deletes_posts() 253 + { 254 + Post::create([ 255 + 'did' => 'did:plc:test123', 256 + 'rkey' => 'test', 257 + 'text' => 'Test post', 258 + ]); 259 + 260 + $signal = new StorePostSignal(); 261 + 262 + $event = $this->createDeleteEvent(); 263 + 264 + $signal->handle($event); 265 + 266 + $this->assertDatabaseMissing('posts', [ 267 + 'did' => 'did:plc:test123', 268 + 'rkey' => 'test', 269 + ]); 270 + } 271 + } 272 + ``` 273 + 274 + ### Test with External APIs 275 + 276 + ```php 277 + use Illuminate\Support\Facades\Http; 278 + 279 + /** @test */ 280 + public function it_sends_notifications() 281 + { 282 + Http::fake(); 283 + 284 + $signal = new NotificationSignal(); 285 + 286 + $event = $this->createEvent(); 287 + 288 + $signal->handle($event); 289 + 290 + Http::assertSent(function ($request) { 291 + return $request->url() === 'https://api.example.com/notify' && 292 + $request['text'] === 'New post created'; 293 + }); 294 + } 295 + ``` 296 + 297 + ## Testing Queued Signals 298 + 299 + Test Signals that use queues. 300 + 301 + ### Test Queue Dispatch 302 + 303 + ```php 304 + use Illuminate\Support\Facades\Queue; 305 + 306 + /** @test */ 307 + public function it_queues_events() 308 + { 309 + Queue::fake(); 310 + 311 + $signal = new QueuedSignal(); 312 + 313 + $this->assertTrue($signal->shouldQueue()); 314 + 315 + // In production, this would queue 316 + // For testing, we verify the intent 317 + } 318 + ``` 319 + 320 + ### Test with Sync Queue 321 + 322 + Process queued jobs synchronously in tests: 323 + 324 + ```php 325 + /** @test */ 326 + public function it_processes_queued_events() 327 + { 328 + // Use sync queue for immediate processing 329 + config(['queue.default' => 'sync']); 330 + 331 + $signal = new QueuedSignal(); 332 + 333 + $event = $this->createEvent(); 334 + 335 + $signal->handle($event); 336 + 337 + // Assert side effects happened 338 + $this->assertDatabaseHas('posts', [...]); 339 + } 340 + ``` 341 + 342 + ### Test Queue Configuration 343 + 344 + ```php 345 + /** @test */ 346 + public function it_uses_high_priority_queue() 347 + { 348 + $signal = new HighPrioritySignal(); 349 + 350 + $this->assertTrue($signal->shouldQueue()); 351 + $this->assertEquals('high-priority', $signal->queue()); 352 + } 353 + 354 + /** @test */ 355 + public function it_uses_redis_connection() 356 + { 357 + $signal = new RedisQueueSignal(); 358 + 359 + $this->assertEquals('redis', $signal->queueConnection()); 360 + } 361 + ``` 362 + 363 + ## Testing Failure Handling 364 + 365 + Test how your Signal handles errors. 366 + 367 + ### Test Failed Method 368 + 369 + ```php 370 + use Illuminate\Support\Facades\Log; 371 + 372 + /** @test */ 373 + public function it_logs_failures() 374 + { 375 + Log::spy(); 376 + 377 + $signal = new FailureHandlingSignal(); 378 + 379 + $event = $this->createEvent(); 380 + $exception = new \Exception('Something went wrong'); 381 + 382 + $signal->failed($event, $exception); 383 + 384 + Log::shouldHaveReceived('error') 385 + ->with('Signal failed', \Mockery::any()); 386 + } 387 + ``` 388 + 389 + ### Test Exception Handling 390 + 391 + ```php 392 + /** @test */ 393 + public function it_handles_invalid_data_gracefully() 394 + { 395 + $signal = new RobustSignal(); 396 + 397 + $event = new SignalEvent( 398 + did: 'did:plc:test', 399 + timeUs: time() * 1000000, 400 + kind: 'commit', 401 + commit: new CommitEvent( 402 + rev: 'test', 403 + operation: 'create', 404 + collection: 'app.bsky.feed.post', 405 + rkey: 'test', 406 + record: (object) [], // Missing required fields 407 + ), 408 + ); 409 + 410 + // Should not throw 411 + $signal->handle($event); 412 + 413 + // Should handle gracefully (e.g., log and skip) 414 + $this->assertDatabaseCount('posts', 0); 415 + } 416 + ``` 417 + 418 + ## Test Helpers 419 + 420 + Create reusable helpers for common test scenarios. 421 + 422 + ### Event Factory Helper 423 + 424 + ```php 425 + trait CreatesSignalEvents 426 + { 427 + protected function createCommitEvent(array $overrides = []): SignalEvent 428 + { 429 + $defaults = [ 430 + 'did' => 'did:plc:test123', 431 + 'timeUs' => time() * 1000000, 432 + 'kind' => 'commit', 433 + 'commit' => new CommitEvent( 434 + rev: 'test', 435 + operation: $overrides['operation'] ?? 'create', 436 + collection: $overrides['collection'] ?? 'app.bsky.feed.post', 437 + rkey: $overrides['rkey'] ?? 'test', 438 + record: (object) array_merge([ 439 + 'text' => 'Test post', 440 + 'createdAt' => now()->toIso8601String(), 441 + ], $overrides['record'] ?? []), 442 + ), 443 + ]; 444 + 445 + return new SignalEvent(...$defaults); 446 + } 447 + 448 + protected function createPostEvent(array $record = []): SignalEvent 449 + { 450 + return $this->createCommitEvent([ 451 + 'collection' => 'app.bsky.feed.post', 452 + 'record' => $record, 453 + ]); 454 + } 455 + 456 + protected function createLikeEvent(array $record = []): SignalEvent 457 + { 458 + return $this->createCommitEvent([ 459 + 'collection' => 'app.bsky.feed.like', 460 + 'record' => array_merge([ 461 + 'subject' => (object) [ 462 + 'uri' => 'at://did:plc:test/app.bsky.feed.post/test', 463 + 'cid' => 'bafytest', 464 + ], 465 + 'createdAt' => now()->toIso8601String(), 466 + ], $record), 467 + ]); 468 + } 469 + 470 + protected function createFollowEvent(array $record = []): SignalEvent 471 + { 472 + return $this->createCommitEvent([ 473 + 'collection' => 'app.bsky.graph.follow', 474 + 'record' => array_merge([ 475 + 'subject' => 'did:plc:target', 476 + 'createdAt' => now()->toIso8601String(), 477 + ], $record), 478 + ]); 479 + } 480 + } 481 + ``` 482 + 483 + Use in tests: 484 + 485 + ```php 486 + class MySignalTest extends TestCase 487 + { 488 + use CreatesSignalEvents; 489 + 490 + /** @test */ 491 + public function it_handles_posts() 492 + { 493 + $event = $this->createPostEvent([ 494 + 'text' => 'Custom text', 495 + ]); 496 + 497 + // Test with event 498 + } 499 + } 500 + ``` 501 + 502 + ### Signal Factory Helper 503 + 504 + ```php 505 + trait CreatesSignals 506 + { 507 + protected function createSignal(string $class, array $config = []) 508 + { 509 + $signal = new $class(); 510 + 511 + // Override configuration for testing 512 + foreach ($config as $method => $value) { 513 + $signal->{$method} = $value; 514 + } 515 + 516 + return $signal; 517 + } 518 + } 519 + ``` 520 + 521 + ## Testing Best Practices 522 + 523 + ### Use Descriptive Test Names 524 + 525 + ```php 526 + // Good 527 + /** @test */ 528 + public function it_stores_posts_with_valid_data() 529 + 530 + /** @test */ 531 + public function it_skips_posts_without_text() 532 + 533 + /** @test */ 534 + public function it_handles_duplicate_posts_gracefully() 535 + 536 + // Less descriptive 537 + /** @test */ 538 + public function test_handle() 539 + ``` 540 + 541 + ### Test Edge Cases 542 + 543 + ```php 544 + /** @test */ 545 + public function it_handles_empty_text() 546 + { 547 + $event = $this->createPostEvent(['text' => '']); 548 + // Test behavior 549 + } 550 + 551 + /** @test */ 552 + public function it_handles_very_long_text() 553 + { 554 + $event = $this->createPostEvent(['text' => str_repeat('a', 10000)]); 555 + // Test behavior 556 + } 557 + 558 + /** @test */ 559 + public function it_handles_missing_created_at() 560 + { 561 + $event = $this->createPostEvent(['createdAt' => null]); 562 + // Test behavior 563 + } 564 + ``` 565 + 566 + ### Test All Operations 567 + 568 + ```php 569 + /** @test */ 570 + public function it_handles_creates() 571 + { 572 + $event = $this->createEvent(['operation' => 'create']); 573 + // Test 574 + } 575 + 576 + /** @test */ 577 + public function it_handles_updates() 578 + { 579 + $event = $this->createEvent(['operation' => 'update']); 580 + // Test 581 + } 582 + 583 + /** @test */ 584 + public function it_handles_deletes() 585 + { 586 + $event = $this->createEvent(['operation' => 'delete']); 587 + // Test 588 + } 589 + ``` 590 + 591 + ### Mock External Dependencies 592 + 593 + ```php 594 + /** @test */ 595 + public function it_calls_external_api() 596 + { 597 + Http::fake([ 598 + 'api.example.com/*' => Http::response(['success' => true]), 599 + ]); 600 + 601 + $signal = new ApiSignal(); 602 + $event = $this->createEvent(); 603 + 604 + $signal->handle($event); 605 + 606 + Http::assertSent(function ($request) { 607 + return $request->url() === 'https://api.example.com/endpoint'; 608 + }); 609 + } 610 + ``` 611 + 612 + ### Test Database State 613 + 614 + ```php 615 + use Illuminate\Foundation\Testing\RefreshDatabase; 616 + 617 + class DatabaseSignalTest extends TestCase 618 + { 619 + use RefreshDatabase; 620 + 621 + /** @test */ 622 + public function it_creates_records() 623 + { 624 + // Fresh database for each test 625 + } 626 + } 627 + ``` 628 + 629 + ## Continuous Integration 630 + 631 + Run tests automatically on every commit. 632 + 633 + ### GitHub Actions 634 + 635 + ```yaml 636 + # .github/workflows/tests.yml 637 + name: Tests 638 + 639 + on: [push, pull_request] 640 + 641 + jobs: 642 + test: 643 + runs-on: ubuntu-latest 644 + 645 + steps: 646 + - uses: actions/checkout@v2 647 + 648 + - name: Setup PHP 649 + uses: shivammathur/setup-php@v2 650 + with: 651 + php-version: 8.2 652 + 653 + - name: Install Dependencies 654 + run: composer install 655 + 656 + - name: Run Tests 657 + run: php artisan test 658 + ``` 659 + 660 + ### Run Signal-Specific Tests 661 + 662 + ```bash 663 + # Run all Signal tests 664 + php artisan test --testsuite=Signals 665 + 666 + # Run specific test file 667 + php artisan test tests/Unit/Signals/NewPostSignalTest.php 668 + 669 + # Run with coverage 670 + php artisan test --coverage 671 + ``` 672 + 673 + ## Debugging Tests 674 + 675 + ### Enable Debug Output 676 + 677 + ```php 678 + /** @test */ 679 + public function it_processes_events() 680 + { 681 + $signal = new NewPostSignal(); 682 + 683 + $event = $this->createEvent(); 684 + 685 + dump($event); // Output event data 686 + 687 + $signal->handle($event); 688 + 689 + dump(Post::all()); // Output results 690 + } 691 + ``` 692 + 693 + ### Use dd() to Stop Execution 694 + 695 + ```php 696 + /** @test */ 697 + public function it_processes_events() 698 + { 699 + $event = $this->createEvent(); 700 + 701 + dd($event); // Dump and die 702 + 703 + // This won't run 704 + } 705 + ``` 706 + 707 + ### Check Logs 708 + 709 + ```php 710 + /** @test */ 711 + public function it_logs_processing() 712 + { 713 + Log::spy(); 714 + 715 + $signal = new LoggingSignal(); 716 + $event = $this->createEvent(); 717 + 718 + $signal->handle($event); 719 + 720 + Log::shouldHaveReceived('info')->once(); 721 + } 722 + ``` 723 + 724 + ## Next Steps 725 + 726 + - **[See real-world examples →](examples.md)** - Learn from production test patterns 727 + - **[Review queue integration →](queues.md)** - Test queued Signals 728 + - **[Review signals documentation →](signals.md)** - Understand Signal structure
header.png

This is a binary file and will not be displayed.