├── .cursor └── rules │ └── cursor-rules-location.mdc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── segment.php ├── laravel-segment-banner.svg ├── phpstan.neon.dist ├── phpunit.xml.dist.bak └── src ├── Contracts ├── CanBeIdentifiedForSegment.php ├── CanBeSentToSegment.php ├── CanNotifyViaSegment.php ├── SegmentServiceContract.php └── ShouldBeAnonymouslyIdentified.php ├── Enums └── SegmentPayloadType.php ├── Exceptions ├── NotifiableCannotBeIdentifiedForSegmentException.php └── UnsupportedSegmentPayloadTypeException.php ├── Facades ├── Fakes │ └── SegmentFake.php └── Segment.php ├── LaravelSegmentServiceProvider.php ├── Middleware └── ApplySegmentGlobals.php ├── Notifications └── SegmentChannel.php ├── PendingUserSegment.php ├── SegmentService.php ├── SimpleSegmentAnonymousUser.php ├── SimpleSegmentEvent.php ├── SimpleSegmentIdentify.php ├── SimpleSegmentUser.php ├── Traits └── HasSegmentIdentityByKey.php └── ValueObjects └── SegmentPayload.php /.cursor/rules/cursor-rules-location.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Rules for placing and organizing Cursor rule files in the repository. 3 | globs: *.mdc 4 | --- 5 | # Cursor Rules Location 6 | 7 | Rules for placing and organizing Cursor rule files in the repository. 8 | 9 | 10 | name: cursor_rules_location 11 | description: Standards for placing Cursor rule files in the correct directory 12 | filters: 13 | # Match any .mdc files 14 | - type: file_extension 15 | pattern: "\\.mdc$" 16 | # Match files that look like Cursor rules 17 | - type: content 18 | pattern: "(?s).*?" 19 | # Match file creation events 20 | - type: event 21 | pattern: "file_create" 22 | 23 | actions: 24 | - type: reject 25 | conditions: 26 | - pattern: "^(?!\\.\\/\\.cursor\\/rules\\/.*\\.mdc$)" 27 | message: "Cursor rule files (.mdc) must be placed in the .cursor/rules directory" 28 | 29 | - type: suggest 30 | message: | 31 | When creating Cursor rules: 32 | 33 | 1. Always place rule files in PROJECT_ROOT/.cursor/rules/: 34 | ``` 35 | .cursor/rules/ 36 | ├── your-rule-name.mdc 37 | ├── another-rule.mdc 38 | └── ... 39 | ``` 40 | 41 | 2. Follow the naming convention: 42 | - Use kebab-case for filenames 43 | - Always use .mdc extension 44 | - Make names descriptive of the rule's purpose 45 | 46 | 3. Directory structure: 47 | ``` 48 | PROJECT_ROOT/ 49 | ├── .cursor/ 50 | │ └── rules/ 51 | │ ├── your-rule-name.mdc 52 | │ └── ... 53 | └── ... 54 | ``` 55 | 56 | 4. Never place rule files: 57 | - In the project root 58 | - In subdirectories outside .cursor/rules 59 | - In any other location 60 | 61 | examples: 62 | - input: | 63 | # Bad: Rule file in wrong location 64 | rules/my-rule.mdc 65 | my-rule.mdc 66 | .rules/my-rule.mdc 67 | 68 | # Good: Rule file in correct location 69 | .cursor/rules/my-rule.mdc 70 | output: "Correctly placed Cursor rule file" 71 | 72 | metadata: 73 | priority: high 74 | version: 1.0 75 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-segment` will be documented in this file. 4 | 5 | ## 1.0.0 - 202X-XX-XX 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) SlashEquip 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Segment 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/slashequip/laravel-segment.svg?style=flat-square)](https://packagist.org/packages/slashequip/laravel-segment) 4 | [![tests](https://github.com/slashequip/laravel-segment/actions/workflows/run-tests.yml/badge.svg?branch=main)](https://github.com/slashequip/laravel-segment/actions/workflows/run-tests.yml) 5 | [![code style](https://github.com/slashequip/laravel-segment/actions/workflows/code-style.yml/badge.svg?branch=main)](https://github.com/slashequip/laravel-segment/actions/workflows/php-cs-fixer.yml) 6 | [![psalm](https://github.com/slashequip/laravel-segment/actions/workflows/static-analysis.yml/badge.svg?branch=main)](https://github.com/slashequip/laravel-segment/actions/workflows/psalm.yml) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/slashequip/laravel-segment.svg?style=flat-square)](https://packagist.org/packages/slashequip/laravel-segment) 8 | 9 | ![Laravel Segment Logo Banner](https://github.com/slashequip/laravel-segment/blob/main/laravel-segment-banner.svg?raw=true) 10 | 11 | Laravel Segment is an opinionated, approach to integrating Segment into your Laravel application. 12 | 13 | 14 | ## Installation 15 | 16 | You can install the package via composer: 17 | 18 | ```bash 19 | composer require slashequip/laravel-segment 20 | ``` 21 | 22 | 23 | You can publish the config file with: 24 | ```bash 25 | php artisan vendor:publish --provider="SlashEquip\LaravelSegment\LaravelSegmentServiceProvider" 26 | ``` 27 | 28 | This is the contents of the published config file, which should be located at `config/segment.php`: 29 | 30 | ```php 31 | return [ 32 | 'enabled' => env('SEGMENT_ENABLED', true), 33 | 34 | /** 35 | * This is your Segment API write key. It can be 36 | * found under Source > Settings > Api Keys 37 | */ 38 | 'write_key' => env('SEGMENT_WRITE_KEY', null), 39 | 40 | /** 41 | * Should the Segment service defer all tracking 42 | * api calls until after the response, sending 43 | * everything using the bulk/batch api? 44 | */ 45 | 'defer' => env('SEGMENT_DEFER', false), 46 | 47 | /** 48 | * Should the Segment service be run in safe mode. 49 | * Safe mode will only report errors in sending 50 | * when safe mode is off exceptions are thrown 51 | */ 52 | 'safe_mode' => env('SEGMENT_SAFE_MODE', true), 53 | ]; 54 | ``` 55 | 56 | ## Setting your write key 57 | 58 | Your write key is the API key given to you by Segment which can be found under your PHP source settings; 59 | `https://app.segment.com/{your-workspace-name}/sources/{your-source-name}/settings/keys` in the Segment UI. 60 | 61 | ## What is a Segment User 62 | 63 | When we talk about a 'user' in the context of this package we mean any object that 64 | implements the `SlashEquip\LaravelSegment\Contracts\CanBeIdentifiedForSegment` contract 65 | the package comes with a trait (and the interface) you can attach to your default 66 | User model; 67 | 68 | ```php 69 | use Illuminate\Database\Eloquent\Model; 70 | use SlashEquip\LaravelSegment\Traits\HasSegmentIdentityByKey; 71 | use SlashEquip\LaravelSegment\Contracts\CanBeIdentifiedForSegment; 72 | 73 | class User extends Model implements CanBeIdentifiedForSegment 74 | { 75 | use HasSegmentIdentityByKey; 76 | } 77 | ``` 78 | 79 | Using this trait will automagically use your users' primary key as the identifier 80 | that is sent to Segment. Alternatively, you can implement your own instance of the 81 | `public function getSegmentIdentifier(): string;` method on your User model and not 82 | use the trait. 83 | 84 | ### Globally identifying users 85 | 86 | If you are sending Segment events in multiple places through your application and 87 | through-out a request it might make sense to globally identify a user to make it 88 | more convenient when making tracking calls. 89 | 90 | ```php 91 | use SlashEquip\LaravelSegment\Facades\Segment; 92 | 93 | Segment::setGlobalUser($user); 94 | ``` 95 | 96 | ### Globally setting context 97 | 98 | Segment allows you to send [context](https://segment.com/docs/connections/spec/common/#context) 99 | with your tracking events too, you can set a global context that applies to all tracking events. 100 | 101 | ```php 102 | use SlashEquip\LaravelSegment\Facades\Segment; 103 | 104 | Segment::setGlobalContext([ 105 | 'ip' => '127.0.0.1', 106 | 'locale' => 'en-US', 107 | 'screen' => [ 108 | 'height' => 1080, 109 | 'width' => 1920, 110 | ], 111 | ]); 112 | ``` 113 | 114 | ### Here have some convenience 115 | 116 | Laravel Segment ships with a middleware that you can apply in your HTTP Kernal that will handle 117 | the setting of the global user and some sensible global context too. It should be simple to extend 118 | this middleware and adjust for your needs if you want to add to the default context provided. 119 | 120 | ```php 121 | 'api' => [ 122 | // ... other middleware 123 | SlashEquip\LaravelSegment\Middleware\ApplySegmentGlobals::class 124 | ], 125 | ``` 126 | 127 | ## Usage 128 | 129 | ### For tracking events 130 | ```php 131 | use SlashEquip\LaravelSegment\Facades\Segment; 132 | 133 | Segment::forUser($user)->track('User Signed Up', [ 134 | 'source' => 'Product Hunt', 135 | ]); 136 | 137 | // If you have set a global user you can 138 | // use the simpler provided syntax. 139 | Segment::track('User Signed Up', [ 140 | 'source' => 'Product Hunt', 141 | ]); 142 | 143 | // If you have defer enabled in the config 144 | // you can still track an event immediately using trackNow. 145 | Segment::trackNow('User Signed Up', [ 146 | 'source' => 'Product Hunt', 147 | ]); 148 | ``` 149 | 150 | ### For identifying users 151 | ```php 152 | use SlashEquip\LaravelSegment\Facades\Segment; 153 | 154 | Segment::forUser($user)->identify([ 155 | 'last_logged_in' => '2021-03-24 20:05:30', 156 | 'latest_subscription_amount' => '$24.60', 157 | ]); 158 | 159 | // If you have set a global user you can 160 | // use the simpler provided syntax. 161 | Segment::identify([ 162 | 'last_logged_in' => '2021-03-24 20:05:30', 163 | 'latest_subscription_amount' => '$24.60', 164 | ]); 165 | 166 | // If you have defer enabled in the config 167 | // you can still identify a user immediately using identifyNow. 168 | Segment::identifyNow([ 169 | 'source' => 'Product Hunt', 170 | ]); 171 | ``` 172 | 173 | ### Anonymous users 174 | 175 | Segment allows you to track events for users that are not yet users in your system, they call these anonymous users. 176 | 177 | To track events for anonymous users you can use apply the `ShouldBeAnonymouslyIdentified` interface to any `CanBeIdentifiedForSegment` implementation. 178 | 179 | ```php 180 | use SlashEquip\LaravelSegment\Contracts\CanBeIdentifiedForSegment; 181 | use SlashEquip\LaravelSegment\Contracts\ShouldBeAnonymouslyIdentified; 182 | 183 | class SegmentAnonymousTestUser implements CanBeIdentifiedForSegment, ShouldBeAnonymouslyIdentified 184 | { 185 | } 186 | ``` 187 | 188 | For convenience, the package comes with a `SimpleSegmentAnonymousUser` class that implements the `ShouldBeAnonymouslyIdentified` interface. 189 | 190 | ```php 191 | use SlashEquip\LaravelSegment\SimpleSegmentAnonymousUser; 192 | 193 | Segment::forUser(new SimpleSegmentAnonymousUser('123'))->track('Kitchen sink used'); 194 | ``` 195 | 196 | ### Laravel Notifications 197 | This package includes an out-of-the-box notification channel, to allow you to use Laravel's built-in notification 198 | feature. To send Segment events to users as notifications, generate your notification as normal; 199 | 200 | ``` 201 | php artisan make:notification UserSubscribed 202 | ``` 203 | 204 | You must ensure your notification implements the `CanNotifyViaSegment` interface, and add the required `toSegment` 205 | method. Then you can configure the `via` method to include the `SegmentChannel` class. 206 | 207 | You can then adjust the `toSegment` method to return the event you'd like. 208 | 209 | ```php 210 | use Illuminate\Notifications\Notification; 211 | use SlashEquip\LaravelSegment\Contracts\CanBeIdentifiedForSegment; 212 | use SlashEquip\LaravelSegment\Contracts\CanBeSentToSegment; 213 | use SlashEquip\LaravelSegment\Contracts\CanNotifyViaSegment; 214 | use SlashEquip\LaravelSegment\Notifications\SegmentChannel; 215 | use SlashEquip\LaravelSegment\SimpleSegmentEvent; 216 | 217 | class UserSubscribed extends Notification implements CanNotifyViaSegment 218 | { 219 | public function __construct( 220 | ) { 221 | } 222 | 223 | public function via(object $notifiable): array 224 | { 225 | return [SegmentChannel::class]; 226 | } 227 | 228 | public function toSegment(CanBeIdentifiedForSegment $notifiable): CanBeSentToSegment 229 | { 230 | return new SimpleSegmentEvent( 231 | $notifiable, 232 | 'User Subscribed', 233 | [ 234 | 'plan' => 'basic', 235 | 'team_name' => 'Funky chickens', 236 | ], 237 | ); 238 | } 239 | } 240 | ``` 241 | 242 | ## Misc 243 | 244 | ### Deferring 245 | When you start to fire many events in your application, even 2-3 per request it can be hyper-beneficial to 246 | turn on deferring (see config). When deferring is enabled, the service will store all of your tracking events triggered 247 | through-out the request or process and then send them in batch after your application has responded to your user. This 248 | happens during the Laravel termination. 249 | 250 | ### Safe mode 251 | By default, safe-mode is turned on. When safe-mode is active it will swallow any exceptions thrown when making the HTTP 252 | request to Segment and report them automatically to the exception handler, allow your app to continue running. When 253 | disabled then the exception will be thrown. 254 | 255 | ## Testing 256 | 257 | ```bash 258 | ./vendor/bin/pest 259 | ``` 260 | 261 | ### Using `Segment::fake()` in tests 262 | 263 | To prevent real HTTP requests when running your own test suite, call 264 | `Segment::fake()` before executing the code under test. This swaps the 265 | Segment service with a fake that records events and identity calls. You can 266 | then use assertion helpers to verify what was sent: 267 | 268 | ```php 269 | use SlashEquip\LaravelSegment\Facades\Segment; 270 | 271 | Segment::fake(); 272 | 273 | // Code that should trigger Segment events 274 | 275 | Segment::assertTracked('User Signed Up'); 276 | Segment::assertIdentified(); 277 | ``` 278 | 279 | ## Changelog 280 | 281 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 282 | 283 | ## Contributing 284 | 285 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 286 | 287 | ## Security Vulnerabilities 288 | 289 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 290 | 291 | ## Credits 292 | 293 | - [SlashEquip](https://github.com/slashequip) 294 | - [All Contributors](../../contributors) 295 | 296 | ## License 297 | 298 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 299 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slashequip/laravel-segment", 3 | "description": "Laravel Segment is an opinionated, approach to integrating Segment into your Laravel application.", 4 | "keywords": [ 5 | "laravel-segment" 6 | ], 7 | "homepage": "https://github.com/slashequip/laravel-segment", 8 | "license": "MIT", 9 | "authors": [ 10 | { 11 | "name": "Sam Jones", 12 | "email": "sam@slashequip.com", 13 | "role": "Developer" 14 | } 15 | ], 16 | "require": { 17 | "php": "^8.2", 18 | "guzzlehttp/guzzle": "^7.8", 19 | "illuminate/contracts": "^11.0|^12.0" 20 | }, 21 | "require-dev": { 22 | "larastan/larastan": "^3.3", 23 | "laravel/pint": "^1.18", 24 | "mockery/mockery": "^1.6", 25 | "orchestra/testbench": "^9.0|^10.0", 26 | "pestphp/pest": "^3.7" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "SlashEquip\\LaravelSegment\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "SlashEquip\\LaravelSegment\\Tests\\": "tests" 36 | } 37 | }, 38 | "scripts": { 39 | "analyse": "vendor/bin/phpstan", 40 | "format": "vendor/bin/pint", 41 | "test": "vendor/bin/pest" 42 | }, 43 | "config": { 44 | "sort-packages": true, 45 | "allow-plugins": { 46 | "pestphp/pest-plugin": true 47 | } 48 | }, 49 | "extra": { 50 | "laravel": { 51 | "providers": [ 52 | "SlashEquip\\LaravelSegment\\LaravelSegmentServiceProvider" 53 | ], 54 | "aliases": { 55 | "Segment": "SlashEquip\\LaravelSegment\\Facades\\Segment" 56 | } 57 | } 58 | }, 59 | "minimum-stability": "dev", 60 | "prefer-stable": true 61 | } 62 | -------------------------------------------------------------------------------- /config/segment.php: -------------------------------------------------------------------------------- 1 | env('SEGMENT_ENABLED', true), 5 | 6 | /** 7 | * This is your Segment API write key. It can be 8 | * found under Source > Settings > Api Keys 9 | */ 10 | 'write_key' => env('SEGMENT_WRITE_KEY', null), 11 | 12 | /** 13 | * Should the Segment service defer all tracking 14 | * api calls until after the response, sending 15 | * everything using the bulk/batch api? 16 | */ 17 | 'defer' => env('SEGMENT_DEFER', false), 18 | 19 | /** 20 | * Should the Segment service be run in safe mode. 21 | * Safe mode will only report errors in sending 22 | * when safe mode is off exceptions are thrown 23 | */ 24 | 'safe_mode' => env('SEGMENT_SAFE_MODE', true), 25 | ]; 26 | -------------------------------------------------------------------------------- /laravel-segment-banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - src/ 8 | 9 | # Level 9 is the highest level 10 | level: 6 11 | -------------------------------------------------------------------------------- /phpunit.xml.dist.bak: -------------------------------------------------------------------------------- 1 | 2 | 21 | 22 | 23 | tests 24 | 25 | 26 | 27 | 28 | ./src 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Contracts/CanBeIdentifiedForSegment.php: -------------------------------------------------------------------------------- 1 | $globalContext 13 | */ 14 | public function setGlobalContext(array $globalContext): void; 15 | 16 | /** 17 | * @param array $eventData 18 | */ 19 | public function track(string $event, ?array $eventData = null): void; 20 | 21 | /** 22 | * @param array $eventData 23 | */ 24 | public function trackNow(string $event, ?array $eventData = null): void; 25 | 26 | /** 27 | * @param array $identifyData 28 | */ 29 | public function identify(?array $identifyData = null): void; 30 | 31 | /** 32 | * @param array $identifyData 33 | */ 34 | public function identifyNow(?array $identifyData = null): void; 35 | 36 | public function forUser(CanBeIdentifiedForSegment $user): PendingUserSegment; 37 | 38 | public function push(CanBeSentToSegment $segment): void; 39 | 40 | public function terminate(): void; 41 | } 42 | -------------------------------------------------------------------------------- /src/Contracts/ShouldBeAnonymouslyIdentified.php: -------------------------------------------------------------------------------- 1 | */ 20 | private array $context = []; 21 | 22 | /** @var array */ 23 | private array $events = []; 24 | 25 | /** @var array */ 26 | private array $identities = []; 27 | 28 | public function setGlobalUser(CanBeIdentifiedForSegment $globalUser): void 29 | { 30 | $this->user = $globalUser; 31 | } 32 | 33 | /** 34 | * @param array $globalContext 35 | */ 36 | public function setGlobalContext(array $globalContext): void 37 | { 38 | $this->context = $globalContext; 39 | } 40 | 41 | /** 42 | * @param array $identifyData 43 | */ 44 | public function identify(?array $identifyData = null): void 45 | { 46 | $this->identities[] = new SimpleSegmentIdentify( 47 | $this->user, 48 | $identifyData 49 | ); 50 | } 51 | 52 | /** 53 | * @param array $identifyData 54 | */ 55 | public function identifyNow(?array $identifyData = null): void 56 | { 57 | $this->identities[] = new SimpleSegmentIdentify( 58 | $this->user, 59 | $identifyData 60 | ); 61 | } 62 | 63 | /** 64 | * @param array $eventData 65 | */ 66 | public function track(string $event, ?array $eventData = null): void 67 | { 68 | $this->events[] = new SimpleSegmentEvent( 69 | $this->user, 70 | $event, 71 | $eventData 72 | ); 73 | } 74 | 75 | /** 76 | * @param array $eventData 77 | */ 78 | public function trackNow(string $event, ?array $eventData = null): void 79 | { 80 | $this->events[] = new SimpleSegmentEvent( 81 | $this->user, 82 | $event, 83 | $eventData 84 | ); 85 | } 86 | 87 | public function forUser(CanBeIdentifiedForSegment $user): PendingUserSegment 88 | { 89 | $this->user = $user; 90 | 91 | return new PendingUserSegment($this, $user); 92 | } 93 | 94 | public function push(CanBeSentToSegment $segment): void 95 | { 96 | if ($segment instanceof SimpleSegmentIdentify) { 97 | $this->identities[] = $segment; 98 | } 99 | 100 | if ($segment instanceof SimpleSegmentEvent) { 101 | $this->events[] = $segment; 102 | } 103 | } 104 | 105 | public function terminate(): void {} 106 | 107 | public function assertIdentified(Closure|int|null $callback = null): void 108 | { 109 | if (is_numeric($callback)) { 110 | $this->assertIdentifiedTimes($callback); 111 | 112 | return; 113 | } 114 | 115 | PHPUnit::assertTrue( 116 | $this->identities($callback)->count() > 0, 117 | 'The expected identities were not called.' 118 | ); 119 | } 120 | 121 | public function assertIdentifiedTimes(int $times = 1): void 122 | { 123 | $count = collect($this->identities)->count(); 124 | 125 | PHPUnit::assertSame( 126 | $times, 127 | $count, 128 | "The identity was called {$count} times instead of {$times} times." 129 | ); 130 | } 131 | 132 | public function assertNotIdentified(?Closure $callback = null): void 133 | { 134 | PHPUnit::assertCount( 135 | 0, 136 | $this->identities($callback), 137 | 'The unexpected identity was called.' 138 | ); 139 | } 140 | 141 | public function assertNothingIdentified(): void 142 | { 143 | $identities = collect($this->identities); 144 | 145 | PHPUnit::assertEmpty( 146 | $identities->all(), 147 | $identities->count().' events were found unexpectedly.' 148 | ); 149 | } 150 | 151 | public function assertTracked(Closure|int|null $callback = null): void 152 | { 153 | if (is_numeric($callback)) { 154 | $this->assertTrackedTimes($callback); 155 | 156 | return; 157 | } 158 | 159 | PHPUnit::assertTrue( 160 | $this->events($callback)->count() > 0, 161 | 'The expected events were not called.' 162 | ); 163 | } 164 | 165 | public function assertTrackedTimes(int $times = 1): void 166 | { 167 | $count = collect($this->events)->count(); 168 | 169 | PHPUnit::assertSame( 170 | $times, 171 | $count, 172 | "The event called {$count} times instead of {$times} times." 173 | ); 174 | } 175 | 176 | public function assertEventTracked( 177 | string $event, 178 | Closure|int|null $callback = null 179 | ): void { 180 | PHPUnit::assertTrue( 181 | $this->events($callback, $event)->count() > 0, 182 | 'The expected events were not called.' 183 | ); 184 | } 185 | 186 | public function assertNotTracked(?Closure $callback = null): void 187 | { 188 | PHPUnit::assertCount( 189 | 0, 190 | $this->events($callback), 191 | 'The unexpected event was called.' 192 | ); 193 | } 194 | 195 | public function assertEventNotTracked( 196 | string $event, 197 | Closure|int|null $callback = null 198 | ): void { 199 | PHPUnit::assertCount( 200 | 0, 201 | $this->events($callback, $event), 202 | 'The expected events were not called.' 203 | ); 204 | } 205 | 206 | public function assertNothingTracked(): void 207 | { 208 | $events = collect($this->events); 209 | 210 | PHPUnit::assertEmpty( 211 | $events->all(), 212 | $events->count().' events were found unexpectedly.' 213 | ); 214 | } 215 | 216 | /** 217 | * @return array|null 218 | */ 219 | public function getContext(): ?array 220 | { 221 | return $this->context; 222 | } 223 | 224 | /** 225 | * @return Collection 226 | */ 227 | private function identities(?Closure $callback = null): Collection 228 | { 229 | $identities = collect($this->identities); 230 | 231 | if ($identities->isEmpty()) { 232 | return collect(); 233 | } 234 | 235 | $callback = $callback ?: fn () => true; 236 | 237 | return $identities->filter( 238 | fn (SimpleSegmentIdentify $identity) => $callback($identity) 239 | ); 240 | } 241 | 242 | /** 243 | * @return Collection 244 | */ 245 | private function events( 246 | ?Closure $callback = null, 247 | ?string $event = null 248 | ): Collection { 249 | $events = collect($this->events); 250 | 251 | if ($events->isEmpty()) { 252 | return collect(); 253 | } 254 | 255 | $callback = $callback ?: fn () => true; 256 | 257 | return $events 258 | ->when($event, function (Collection $collection) use ($event) { 259 | return $collection->filter(function ( 260 | SimpleSegmentEvent $segmentEvent 261 | ) use ($event) { 262 | return $segmentEvent->toSegment()->event === $event; 263 | }); 264 | }) 265 | ->filter(fn (SimpleSegmentEvent $event) => $callback($event)); 266 | } 267 | } 268 | -------------------------------------------------------------------------------- /src/Facades/Segment.php: -------------------------------------------------------------------------------- 1 | $globalContext) 16 | * @method static void track(string $event, ?array $eventData = null) 17 | * @method static void trackNow(string $event, ?array $eventData = null) 18 | * @method static void identify(?array $identifyData = null) 19 | * @method static void identifyNow(?array $identifyData = null) 20 | * @method static PendingUserSegment forUser(CanBeIdentifiedForSegment $user) 21 | * @method static void push(CanBeSentToSegment $segment) 22 | * @method static void terminate() 23 | * @method static void assertIdentified(Closure|int|null $callback = null) 24 | * @method static void assertIdentifiedTimes(int $times) 25 | * @method static void assertNotIdentified(Closure $callback = null) 26 | * @method static void assertNothingIdentified() 27 | * @method static void assertTracked(Closure|int|null $callback = null) 28 | * @method static void assertTrackedTimes(int $times) 29 | * @method static void assertEventTracked(string $event,Closure|int|null $callback = null) 30 | * @method static void assertNotTracked(Closure $callback = null) 31 | * @method static void assertNothingTracked() 32 | * 33 | * @see \SlashEquip\LaravelSegment\SegmentService 34 | * @see SegmentFake 35 | */ 36 | class Segment extends Facade 37 | { 38 | protected static function getFacadeAccessor(): string 39 | { 40 | return SegmentServiceContract::class; 41 | } 42 | 43 | public static function fake(): SegmentFake 44 | { 45 | return tap(new SegmentFake, function (SegmentFake $fake) { 46 | static::swap($fake); 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/LaravelSegmentServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 16 | __DIR__.'/../config/segment.php' => config_path('segment.php'), 17 | ]); 18 | 19 | /** 20 | * Send deferred tracking events to Segment after the response has been sent. 21 | * 22 | * @psalm-suppress UndefinedInterfaceMethod 23 | */ 24 | $this->app->terminating(function () { 25 | Segment::terminate(); 26 | }); 27 | 28 | // Send deferred tracking events to Segment after a job has been processed. 29 | Queue::after(function () { 30 | Segment::terminate(); 31 | }); 32 | } 33 | 34 | public function register(): void 35 | { 36 | // Register config. 37 | $this->mergeConfigFrom( 38 | __DIR__.'/../config/segment.php', 39 | 'segment' 40 | ); 41 | 42 | // Register the Segment service. 43 | $this->app->singleton(SegmentServiceContract::class, function ($app) { 44 | return new SegmentService($app->make('config')['segment']); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Middleware/ApplySegmentGlobals.php: -------------------------------------------------------------------------------- 1 | user()) { 19 | /** @var CanBeIdentifiedForSegment $user */ 20 | Segment::setGlobalUser($user); 21 | } 22 | 23 | /** 24 | * Build some nice default context based on the current request. 25 | */ 26 | Segment::setGlobalContext($this->getContext($request)); 27 | 28 | return $next($request); 29 | } 30 | 31 | /** 32 | * @return array 33 | */ 34 | private function getContext(Request $request): array 35 | { 36 | return collect([ 37 | 'ip' => $request->ip(), 38 | 'locale' => $request->getPreferredLanguage(), 39 | 'userAgent' => $request->userAgent(), 40 | 41 | /** 42 | * This is a solid default, generally backend calls 43 | * to Segment are not responsible to determining 44 | * whether or a not a user is active or not. 45 | */ 46 | 'active' => false, 47 | ]) 48 | ->filter(function ($context) { 49 | // Top level null values in the context 50 | // are meaningless at this point. 51 | return ! is_null($context); 52 | }) 53 | ->all(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Notifications/SegmentChannel.php: -------------------------------------------------------------------------------- 1 | toSegment($notifiable)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/PendingUserSegment.php: -------------------------------------------------------------------------------- 1 | |null $eventData 17 | */ 18 | public function track(string $event, ?array $eventData = null): void 19 | { 20 | $this->service->push( 21 | new SimpleSegmentEvent($this->user, $event, $eventData) 22 | ); 23 | } 24 | 25 | /** 26 | * @param array|null $eventData 27 | */ 28 | public function trackNow(string $event, ?array $eventData = null): void 29 | { 30 | $this->service->push( 31 | new SimpleSegmentEvent($this->user, $event, $eventData) 32 | ); 33 | 34 | $this->service->terminate(); 35 | } 36 | 37 | /** 38 | * @param array|null $identifyData 39 | */ 40 | public function identify(?array $identifyData = null): void 41 | { 42 | $this->service->push( 43 | new SimpleSegmentIdentify($this->user, $identifyData) 44 | ); 45 | } 46 | 47 | /** 48 | * @param array|null $identifyData 49 | */ 50 | public function identifyNow(?array $identifyData = null): void 51 | { 52 | $this->service->push( 53 | new SimpleSegmentIdentify($this->user, $identifyData) 54 | ); 55 | 56 | $this->service->terminate(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/SegmentService.php: -------------------------------------------------------------------------------- 1 | */ 22 | private array $globalContext = []; 23 | 24 | /** @var array */ 25 | private array $payloads = []; 26 | 27 | /** 28 | * @param array $config 29 | */ 30 | public function __construct( 31 | private readonly array $config 32 | ) {} 33 | 34 | public function setGlobalUser(CanBeIdentifiedForSegment $globalUser): void 35 | { 36 | $this->globalUser = $globalUser; 37 | } 38 | 39 | /** 40 | * @param array $globalContext 41 | */ 42 | public function setGlobalContext(array $globalContext): void 43 | { 44 | $this->globalContext = $globalContext; 45 | } 46 | 47 | /** 48 | * @param array $eventData 49 | */ 50 | public function track(string $event, ?array $eventData = null): void 51 | { 52 | $this->push( 53 | new SimpleSegmentEvent($this->globalUser, $event, $eventData) 54 | ); 55 | } 56 | 57 | /** 58 | * @param array $eventData 59 | */ 60 | public function trackNow(string $event, ?array $eventData = null): void 61 | { 62 | $this->push( 63 | new SimpleSegmentEvent($this->globalUser, $event, $eventData) 64 | ); 65 | 66 | $this->terminate(); 67 | } 68 | 69 | /** 70 | * @param array $identifyData 71 | */ 72 | public function identify(?array $identifyData = null): void 73 | { 74 | $this->push( 75 | new SimpleSegmentIdentify($this->globalUser, $identifyData) 76 | ); 77 | } 78 | 79 | /** 80 | * @param array $identifyData 81 | */ 82 | public function identifyNow(?array $identifyData = null): void 83 | { 84 | $this->push( 85 | new SimpleSegmentIdentify($this->globalUser, $identifyData) 86 | ); 87 | 88 | $this->terminate(); 89 | } 90 | 91 | public function forUser(CanBeIdentifiedForSegment $user): PendingUserSegment 92 | { 93 | return new PendingUserSegment($this, $user); 94 | } 95 | 96 | public function push(CanBeSentToSegment $segment): void 97 | { 98 | $this->payloads[] = $segment->toSegment(); 99 | 100 | // We are not deferring so we send now! 101 | if (! $this->shouldDefer()) { 102 | $this->terminate(); 103 | } 104 | } 105 | 106 | public function terminate(): void 107 | { 108 | if (empty($this->payloads)) { 109 | return; 110 | } 111 | 112 | if (! $this->isEnabled()) { 113 | $this->clean(); 114 | 115 | return; 116 | } 117 | 118 | // Send the batch request. 119 | $response = Http::asJson() 120 | ->withToken(base64_encode($this->getWriteKey())) 121 | ->post(self::BATCH_URL, [ 122 | 'batch' => $this->getBatchData(), 123 | 'context' => $this->globalContext, 124 | ]); 125 | 126 | // Do error handling. 127 | $this->handleResponseErrors($response); 128 | 129 | // Clean up. 130 | $this->clean(); 131 | } 132 | 133 | /** 134 | * @return array 135 | */ 136 | protected function getBatchData(): array 137 | { 138 | return collect($this->payloads) 139 | ->map(fn (SegmentPayload $payload) => $this->getDataFromPayload($payload)) 140 | ->all(); 141 | } 142 | 143 | /** 144 | * @return array 145 | */ 146 | protected function getDataFromPayload(SegmentPayload $payload): array 147 | { 148 | $key = $payload->user instanceof ShouldBeAnonymouslyIdentified ? 'anonymousId' : 'userId'; 149 | 150 | // Initial data. 151 | $data = [ 152 | 'type' => $payload->type->value, 153 | $key => $payload->user->getSegmentIdentifier(), 154 | 'timestamp' => $payload->timestamp->format('Y-m-d\TH:i:s\Z'), 155 | ]; 156 | 157 | // This is important, Segment will not handle empty 158 | // data arrays as expected and will drop the event. 159 | if (! empty($payload->data)) { 160 | $data[$payload->getDataKey()] = $payload->data; 161 | } 162 | 163 | // If it's a tracking call we need an event name! 164 | if ($payload->type === SegmentPayloadType::Track) { 165 | $data['event'] = $payload->event; 166 | } 167 | 168 | return $data; 169 | } 170 | 171 | protected function handleResponseErrors(Response $response): void 172 | { 173 | \rescue( 174 | callback: function () use ($response) { 175 | // If there was an error then it can be 176 | // thrown here and we can process. 177 | $response->throw(); 178 | }, 179 | rescue: function (Throwable $e) { 180 | // Cleanup and re-throw, we stop here. 181 | if (! $this->inSafeMode()) { 182 | $this->clean(); 183 | 184 | throw $e; 185 | } 186 | 187 | // We report manually to prevent duplicate exceptions reports 188 | report($e); 189 | }, 190 | report: false 191 | ); 192 | } 193 | 194 | protected function clean(): void 195 | { 196 | $this->payloads = []; 197 | } 198 | 199 | protected function isEnabled(): bool 200 | { 201 | return filter_var($this->config['enabled'] ?? true, FILTER_VALIDATE_BOOL); 202 | } 203 | 204 | protected function getWriteKey(): string 205 | { 206 | return sprintf('%s:', $this->config['write_key'] ?? ''); 207 | } 208 | 209 | protected function shouldDefer(): bool 210 | { 211 | return filter_var($this->config['defer'] ?? false, FILTER_VALIDATE_BOOL); 212 | } 213 | 214 | protected function inSafeMode(): bool 215 | { 216 | return filter_var($this->config['safe_mode'] ?? true, FILTER_VALIDATE_BOOL); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/SimpleSegmentAnonymousUser.php: -------------------------------------------------------------------------------- 1 | $eventData 14 | */ 15 | public function __construct( 16 | private readonly CanBeIdentifiedForSegment $user, 17 | private readonly string $event, 18 | private readonly ?array $eventData = null, 19 | ) {} 20 | 21 | public function toSegment(): SegmentPayload 22 | { 23 | return new SegmentPayload( 24 | user: $this->user, 25 | type: SegmentPayloadType::Track, 26 | event: $this->event, 27 | data: $this->eventData ?? [], 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/SimpleSegmentIdentify.php: -------------------------------------------------------------------------------- 1 | $identifyData 14 | */ 15 | public function __construct( 16 | private CanBeIdentifiedForSegment $user, 17 | private ?array $identifyData = null 18 | ) {} 19 | 20 | public function toSegment(): SegmentPayload 21 | { 22 | return new SegmentPayload( 23 | user: $this->user, 24 | type: SegmentPayloadType::Identify, 25 | data: $this->identifyData ?? [], 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/SimpleSegmentUser.php: -------------------------------------------------------------------------------- 1 | id; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Traits/HasSegmentIdentityByKey.php: -------------------------------------------------------------------------------- 1 | getKey(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/ValueObjects/SegmentPayload.php: -------------------------------------------------------------------------------- 1 | $data 16 | */ 17 | public function __construct( 18 | public readonly CanBeIdentifiedForSegment $user, 19 | public readonly SegmentPayloadType $type, 20 | public readonly ?string $event = null, 21 | public readonly array $data = [], 22 | ?DateTime $timestamp = null 23 | ) { 24 | $this->timestamp = $timestamp ?: new DateTime; 25 | $this->timestamp->setTimezone(new DateTimeZone('UTC')); 26 | } 27 | 28 | public function getDataKey(): string 29 | { 30 | return match ($this->type) { 31 | SegmentPayloadType::Track => 'properties', 32 | SegmentPayloadType::Identify => 'traits', 33 | }; 34 | } 35 | } 36 | --------------------------------------------------------------------------------