moicle 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,62 +1,108 @@
1
1
  # Laravel Backend Structure
2
2
 
3
- > Simple Domain + UseCase pattern for Laravel
3
+ > Domain-Driven Design (DDD) Architecture for Laravel
4
4
 
5
- ## Project Structure
5
+ ## Tech Stack
6
+
7
+ - PHP 8.2+, Laravel 11+
8
+ - Eloquent ORM
9
+ - PHPUnit / Pest for testing
10
+ - PHPStan for static analysis
11
+
12
+ ## DDD Directory Structure
6
13
 
7
14
  ```
8
- {project}/
9
- ├── app/
10
- ├── Domain/ # Business logic by feature
11
- │ └── {Feature}/
12
- ├── Entities/ # Value objects, DTOs
13
- ├── Events/ # Domain events
14
- │ ├── Exceptions/ # Feature-specific exceptions
15
- ├── Listeners/ # Event listeners
16
- └── UseCase/ # Action classes
17
- ├── GetUserUseCase.php
18
- │ ├── CreateUserUseCase.php
19
- │ └── UpdateUserUseCase.php
15
+ app/
16
+ ├── Domain/
17
+ └── {Domain}/
18
+ ├── Entities/ # Business objects with behavior
19
+ └── {Entity}.php # Static factory, state transitions
20
+ │ ├── ValueObjects/ # Immutable typed values
21
+ └── {VO}.php # Status, Money, Email
22
+ │ ├── Ports/ # Interfaces (contracts)
23
+ └── {StoreName}Port.php # Repository interface
24
+ ├── Events/ # Domain events
25
+ └── {EventName}.php
26
+ ├── UseCases/ # Business logic
27
+ │ │ └── {Action}UseCase.php
28
+ │ └── Validators/ # Validation rules
29
+
30
+ ├── Application/
20
31
  │ ├── Http/
21
- │ │ ├── Controllers/
22
- │ │ │ ├── Api/ # API controllers
23
- │ │ └── Web/ # Web controllers
24
- │ │ ├── Middleware/
25
- │ ├── Requests/ # Form requests (validation)
26
- └── Resources/ # API resources
27
- ├── Models/ # Eloquent models
28
- ├── Services/ # Shared services (cache, etc.)
29
- └── Providers/
30
- ├── config/
31
- ├── database/
32
+ │ │ └── Controllers/
33
+ │ │ └── {Module}Controller.php
34
+ ├── Services/ # Thin wrappers -> UseCases
35
+ │ │ └── {Module}Service.php
36
+ └── Listeners/ # Event side-effects
37
+ └── On{EventName}Listener.php
38
+
39
+ ├── Infrastructure/
40
+ ├── Repositories/ # Eloquent implementations
41
+ │ │ └── Eloquent{Entity}Repository.php
42
+ │ └── Adapters/ # External services (payment, email, etc.)
43
+
44
+ ├── Models/ # Eloquent models (keep Laravel convention)
45
+
46
+ └── Providers/ # Service providers for DI
47
+ ```
48
+
49
+ ### Supporting Directories
50
+
51
+ ```
52
+ config/
53
+ database/
32
54
  │ ├── factories/
33
55
  │ ├── migrations/
34
56
  │ └── seeders/
35
- ├── routes/
57
+ routes/
36
58
  │ ├── api.php
37
59
  │ └── web.php
38
- ├── tests/
39
- │ ├── Feature/
40
- │ └── Unit/
41
- ├── .claude/
42
- ├── CLAUDE.md
43
- └── composer.json
60
+ tests/
61
+ │ ├── Unit/
62
+ └── Domain/
63
+ │ │ └── {Domain}/
64
+ │ └── Feature/
65
+ ```
66
+
67
+ ## Layer Rules
68
+
69
+ | Layer | Can Import | Cannot Import |
70
+ |-------|-----------|---------------|
71
+ | Domain | PHP stdlib, own domain | Eloquent, Request, Facades, Application, Infrastructure |
72
+ | Application | Domain, Laravel HTTP | Infrastructure (directly) |
73
+ | Infrastructure | Domain (Ports), Eloquent, external libs | Application |
74
+
75
+ **The Domain layer is pure PHP.** No framework dependencies allowed.
76
+
77
+ ## Architecture Flow
78
+
44
79
  ```
80
+ Controller → Service → UseCase → Port (interface)
81
+
82
+ Infrastructure implements Port
83
+ ```
84
+
85
+ 1. Controller receives HTTP request, delegates to Service
86
+ 2. Service is a thin wrapper that calls UseCase
87
+ 3. UseCase contains business logic, depends on Port interfaces
88
+ 4. Infrastructure implements Port interfaces using Eloquent/external APIs
89
+ 5. ServiceProvider wires Port interfaces to Infrastructure implementations
45
90
 
46
- ## Architecture Pattern
91
+ ## Forbidden Imports in Domain Layer
92
+
93
+ These imports must NEVER appear in `app/Domain/`:
47
94
 
48
95
  ```
49
- Controller → UseCase → Model (Eloquent)
50
- ↓ ↓
51
- Request Services (optional: cache, external APIs)
96
+ Illuminate\Database\*
97
+ Illuminate\Http\Request
98
+ Illuminate\Support\Facades\*
99
+ Illuminate\Cache\*
100
+ Illuminate\Queue\*
52
101
  ```
53
102
 
54
- **Simple flow:**
55
- 1. Controller receives request
56
- 2. Controller injects and calls UseCase
57
- 3. UseCase contains business logic
58
- 4. UseCase uses Eloquent Models directly
59
- 5. Controller returns response
103
+ Domain classes depend only on:
104
+ - Other Domain classes (Entities, ValueObjects, Ports, Events)
105
+ - PHP native types and exceptions
60
106
 
61
107
  ## Domain Structure Example
62
108
 
@@ -65,109 +111,248 @@ app/Domain/
65
111
  ├── Story/
66
112
  │ ├── Entities/
67
113
  │ │ └── Story.php
114
+ │ ├── ValueObjects/
115
+ │ │ ├── StoryStatus.php
116
+ │ │ └── Slug.php
117
+ │ ├── Ports/
118
+ │ │ └── StoryStorePort.php
68
119
  │ ├── Events/
69
- │ │ └── StoryCreated.php
70
- │ ├── Exceptions/
71
- │ │ ├── StoryNotFound.php
72
- │ │ └── StoryContentException.php
73
- │ ├── Listeners/
74
- │ │ └── NotifyOnStoryCreated.php
75
- └── UseCase/
76
- ├── GetStoryBySlugUseCase.php
77
- ├── GetAllStoriesUseCase.php
78
- ├── CreateStoryUseCase.php
79
- │ ├── UpdateStoryUseCase.php
80
- │ └── DeleteStoryUseCase.php
120
+ │ │ └── StoryPublished.php
121
+ │ ├── UseCases/
122
+ │ │ ├── GetStoryBySlugUseCase.php
123
+ │ │ ├── GetAllStoriesUseCase.php
124
+ ├── CreateStoryUseCase.php
125
+ │ │ ├── UpdateStoryUseCase.php
126
+ │ ├── PublishStoryUseCase.php
127
+ │ └── DeleteStoryUseCase.php
128
+ └── Validators/
129
+ └── StoryValidator.php
81
130
  ├── User/
82
- └── UseCase/
131
+ ├── Entities/
132
+ │ │ └── User.php
133
+ │ ├── Ports/
134
+ │ │ └── UserStorePort.php
135
+ │ └── UseCases/
83
136
  │ ├── GetUserUseCase.php
84
137
  │ └── CreateUserUseCase.php
85
138
  └── Shared/
86
- └── Payload/
139
+ └── ValueObjects/
87
140
  ├── Filter.php
88
141
  └── Sorter.php
89
142
  ```
90
143
 
91
144
  ## Key Files
92
145
 
93
- ### UseCase Example
146
+ ### Port Interface
94
147
 
95
148
  ```php
96
149
  <?php
97
- // app/Domain/Story/UseCase/GetStoryBySlugUseCase.php
150
+ // app/Domain/Story/Ports/StoryStorePort.php
98
151
 
99
- namespace App\Domain\Story\UseCase;
152
+ namespace App\Domain\Story\Ports;
100
153
 
101
- use App\Domain\Story\Exceptions\StoryNotFound;
102
- use App\Models\Story;
103
- use App\Services\Cache\CacheService;
154
+ use App\Domain\Story\Entities\Story;
155
+
156
+ interface StoryStorePort
157
+ {
158
+ public function findBySlug(string $slug): ?Story;
159
+ public function findAll(array $filters = []): array;
160
+ public function save(Story $story): Story;
161
+ public function delete(string $id): void;
162
+ }
163
+ ```
164
+
165
+ ### Domain Entity
166
+
167
+ ```php
168
+ <?php
169
+ // app/Domain/Story/Entities/Story.php
170
+
171
+ namespace App\Domain\Story\Entities;
172
+
173
+ use App\Domain\Story\ValueObjects\StoryStatus;
174
+ use App\Domain\Story\ValueObjects\Slug;
175
+
176
+ class Story
177
+ {
178
+ private function __construct(
179
+ public readonly ?string $id,
180
+ public readonly string $title,
181
+ public readonly Slug $slug,
182
+ public readonly string $description,
183
+ public readonly string $authorId,
184
+ public StoryStatus $status,
185
+ ) {}
186
+
187
+ public static function create(
188
+ string $title,
189
+ string $description,
190
+ string $authorId,
191
+ ): self {
192
+ return new self(
193
+ id: null,
194
+ title: $title,
195
+ slug: Slug::fromString($title),
196
+ description: $description,
197
+ authorId: $authorId,
198
+ status: StoryStatus::DRAFT,
199
+ );
200
+ }
201
+
202
+ public function publish(): self
203
+ {
204
+ if ($this->status !== StoryStatus::DRAFT) {
205
+ throw new \DomainException('Only draft stories can be published');
206
+ }
207
+
208
+ $this->status = StoryStatus::PUBLISHED;
209
+ return $this;
210
+ }
211
+ }
212
+ ```
213
+
214
+ ### Value Object
215
+
216
+ ```php
217
+ <?php
218
+ // app/Domain/Story/ValueObjects/StoryStatus.php
219
+
220
+ namespace App\Domain\Story\ValueObjects;
221
+
222
+ enum StoryStatus: string
223
+ {
224
+ case DRAFT = 'draft';
225
+ case PUBLISHED = 'published';
226
+ case ARCHIVED = 'archived';
227
+ }
228
+ ```
229
+
230
+ ### UseCase
231
+
232
+ ```php
233
+ <?php
234
+ // app/Domain/Story/UseCases/GetStoryBySlugUseCase.php
235
+
236
+ namespace App\Domain\Story\UseCases;
237
+
238
+ use App\Domain\Story\Entities\Story;
239
+ use App\Domain\Story\Ports\StoryStorePort;
104
240
 
105
241
  class GetStoryBySlugUseCase
106
242
  {
107
243
  public function __construct(
108
- protected Story $story,
109
- protected CacheService $cacheService
244
+ private StoryStorePort $storyStore,
110
245
  ) {}
111
246
 
112
- public function execute(string $slug): object
247
+ public function execute(string $slug): Story
113
248
  {
114
- // Check cache first
115
- $cached = $this->cacheService->get("story:{$slug}");
116
- if ($cached) {
117
- return $cached;
249
+ $story = $this->storyStore->findBySlug($slug);
250
+
251
+ if (!$story) {
252
+ throw new \DomainException("Story '{$slug}' not found");
118
253
  }
119
254
 
120
- // Query with Eloquent
121
- $story = $this->story->newQuery()
122
- ->select(['id', 'title', 'slug', 'description', 'author_id'])
123
- ->with(['genres:id,name,slug', 'author:id,name,slug'])
255
+ return $story;
256
+ }
257
+ }
258
+ ```
259
+
260
+ ### Infrastructure Repository
261
+
262
+ ```php
263
+ <?php
264
+ // app/Infrastructure/Repositories/EloquentStoryRepository.php
265
+
266
+ namespace App\Infrastructure\Repositories;
267
+
268
+ use App\Domain\Story\Entities\Story as StoryEntity;
269
+ use App\Domain\Story\Ports\StoryStorePort;
270
+ use App\Domain\Story\ValueObjects\StoryStatus;
271
+ use App\Domain\Story\ValueObjects\Slug;
272
+ use App\Models\Story;
273
+
274
+ class EloquentStoryRepository implements StoryStorePort
275
+ {
276
+ public function findBySlug(string $slug): ?StoryEntity
277
+ {
278
+ $model = Story::query()
124
279
  ->where('slug', $slug)
125
- ->published()
280
+ ->where('status', StoryStatus::PUBLISHED->value)
126
281
  ->first();
127
282
 
128
- if (!$story) {
129
- throw new StoryNotFound("Story '{$slug}' not found");
283
+ if (!$model) {
284
+ return null;
130
285
  }
131
286
 
132
- // Cache result
133
- $this->cacheService->put("story:{$slug}", $story, 60 * 60);
287
+ return $this->toEntity($model);
288
+ }
134
289
 
135
- return $story;
290
+ public function findAll(array $filters = []): array
291
+ {
292
+ $query = Story::query();
293
+
294
+ if (isset($filters['status'])) {
295
+ $query->where('status', $filters['status']);
296
+ }
297
+
298
+ return $query->get()->map(fn ($m) => $this->toEntity($m))->all();
299
+ }
300
+
301
+ public function save(StoryEntity $story): StoryEntity
302
+ {
303
+ $model = Story::updateOrCreate(
304
+ ['id' => $story->id],
305
+ [
306
+ 'title' => $story->title,
307
+ 'slug' => $story->slug->value,
308
+ 'description' => $story->description,
309
+ 'author_id' => $story->authorId,
310
+ 'status' => $story->status->value,
311
+ ]
312
+ );
313
+
314
+ return $this->toEntity($model);
315
+ }
316
+
317
+ public function delete(string $id): void
318
+ {
319
+ Story::destroy($id);
320
+ }
321
+
322
+ private function toEntity(Story $model): StoryEntity
323
+ {
324
+ // Map Eloquent model to domain entity
136
325
  }
137
326
  }
138
327
  ```
139
328
 
140
- ### Controller Example
329
+ ### Application Controller
141
330
 
142
331
  ```php
143
332
  <?php
144
- // app/Http/Controllers/Web/StoryController.php
333
+ // app/Application/Http/Controllers/StoryController.php
145
334
 
146
- namespace App\Http\Controllers\Web;
335
+ namespace App\Application\Http\Controllers;
147
336
 
148
- use App\Domain\Story\UseCase\GetStoryBySlugUseCase;
149
- use App\Domain\Story\UseCase\GetAllStoriesUseCase;
150
- use App\Domain\Story\Exceptions\StoryNotFound;
151
- use App\Http\Controllers\Controller;
337
+ use App\Domain\Story\UseCases\GetStoryBySlugUseCase;
338
+ use App\Domain\Story\UseCases\GetAllStoriesUseCase;
152
339
  use Illuminate\Http\Request;
153
340
 
154
341
  class StoryController extends Controller
155
342
  {
156
343
  public function __construct(
157
- protected GetStoryBySlugUseCase $getStoryBySlugUseCase
344
+ private GetStoryBySlugUseCase $getStoryBySlug,
345
+ private GetAllStoriesUseCase $getAllStories,
158
346
  ) {}
159
347
 
160
- public function index(
161
- Request $request,
162
- GetAllStoriesUseCase $getAllStoriesUseCase
163
- ) {
348
+ public function index(Request $request)
349
+ {
164
350
  $filters = [
165
351
  'search' => $request->query('search'),
166
352
  'genre' => $request->query('genre'),
167
- 'sort' => $request->query('sort', 'latest'),
168
353
  ];
169
354
 
170
- $stories = $getAllStoriesUseCase->execute($filters);
355
+ $stories = $this->getAllStories->execute($filters);
171
356
 
172
357
  return view('pages.story.index', compact('stories'));
173
358
  }
@@ -175,87 +360,98 @@ class StoryController extends Controller
175
360
  public function show(string $slug)
176
361
  {
177
362
  try {
178
- $story = $this->getStoryBySlugUseCase->execute($slug);
363
+ $story = $this->getStoryBySlug->execute($slug);
179
364
  return view('pages.story.show', compact('story'));
180
- } catch (StoryNotFound $e) {
365
+ } catch (\DomainException $e) {
181
366
  abort(404);
182
367
  }
183
368
  }
184
369
  }
185
370
  ```
186
371
 
187
- ### Exception Example
372
+ ### Wiring: ServiceProvider
188
373
 
189
374
  ```php
190
375
  <?php
191
- // app/Domain/Story/Exceptions/StoryNotFound.php
192
-
193
- namespace App\Domain\Story\Exceptions;
194
-
195
- use Exception;
196
-
197
- class StoryNotFound extends Exception
198
- {
199
- //
200
- }
201
- ```
202
-
203
- ### Shared Payload Example
376
+ // app/Providers/DomainServiceProvider.php
204
377
 
205
- ```php
206
- <?php
207
- // app/Domain/Shared/Payload/Filter.php
378
+ namespace App\Providers;
208
379
 
209
- namespace App\Domain\Shared\Payload;
380
+ use Illuminate\Support\ServiceProvider;
381
+ use App\Domain\Story\Ports\StoryStorePort;
382
+ use App\Infrastructure\Repositories\EloquentStoryRepository;
383
+ use App\Domain\User\Ports\UserStorePort;
384
+ use App\Infrastructure\Repositories\EloquentUserRepository;
210
385
 
211
- class Filter
386
+ class DomainServiceProvider extends ServiceProvider
212
387
  {
213
- public function __construct(
214
- public string $field,
215
- public string $operator,
216
- public mixed $value
217
- ) {}
388
+ public function register(): void
389
+ {
390
+ // Bind port interfaces to infrastructure implementations
391
+ $this->app->bind(StoryStorePort::class, EloquentStoryRepository::class);
392
+ $this->app->bind(UserStorePort::class, EloquentUserRepository::class);
393
+ }
218
394
  }
219
395
  ```
220
396
 
221
- ## Conventions
397
+ ## Naming Conventions
222
398
 
223
399
  | Item | Convention | Example |
224
400
  |------|------------|---------|
401
+ | Entity | PascalCase | `Story`, `User` |
402
+ | ValueObject | PascalCase | `StoryStatus`, `Slug`, `Money` |
403
+ | Port | PascalCase + Port | `StoryStorePort` |
225
404
  | UseCase | PascalCase + UseCase | `GetStoryBySlugUseCase` |
405
+ | Repository | Eloquent + Entity + Repository | `EloquentStoryRepository` |
226
406
  | Controller | PascalCase + Controller | `StoryController` |
407
+ | Service | PascalCase + Service | `StoryService` |
408
+ | Listener | On + EventName + Listener | `OnStoryPublishedListener` |
409
+ | Event | PascalCase (past tense) | `StoryPublished` |
227
410
  | Model | Singular PascalCase | `Story` |
228
- | Exception | PascalCase | `StoryNotFound` |
229
- | Event | PascalCase | `StoryCreated` |
230
- | Request | PascalCase + Request | `StoreStoryRequest` |
231
- | Resource | PascalCase + Resource | `StoryResource` |
232
411
 
233
412
  ## UseCase Naming
234
413
 
235
414
  | Action | Naming Pattern | Example |
236
415
  |--------|----------------|---------|
237
416
  | Get single | `Get{Entity}ByXxxUseCase` | `GetStoryBySlugUseCase` |
238
- | Get list | `Get{Entities}UseCase` | `GetAllStoriesUseCase` |
239
- | Get filtered | `GetXxxUseCase` | `GetLatestStoriesUseCase` |
417
+ | Get list | `GetAll{Entities}UseCase` | `GetAllStoriesUseCase` |
240
418
  | Create | `Create{Entity}UseCase` | `CreateStoryUseCase` |
241
419
  | Update | `Update{Entity}UseCase` | `UpdateStoryUseCase` |
242
420
  | Delete | `Delete{Entity}UseCase` | `DeleteStoryUseCase` |
243
- | Search | `Search{Entities}UseCase` | `SearchStoriesUseCase` |
244
421
  | Action | `{Action}{Entity}UseCase` | `PublishStoryUseCase` |
422
+ | Search | `Search{Entities}UseCase` | `SearchStoriesUseCase` |
245
423
 
246
- ## When to Create UseCase
424
+ ## Hard Rules
247
425
 
248
- **Create UseCase when:**
249
- - Business logic is reusable across controllers
250
- - Logic is complex (multiple model interactions)
251
- - Logic needs caching, events, or validation
252
- - Action is a distinct business operation
426
+ 1. Domain layer has ZERO framework imports
427
+ 2. UseCases depend only on Ports, never on concrete repositories
428
+ 3. Eloquent Models live in `app/Models/`, never in Domain
429
+ 4. Infrastructure implements Domain Ports
430
+ 5. Controllers never call repositories directly
431
+ 6. Every Port must have at least one Infrastructure implementation
432
+ 7. Domain Events carry only primitive data or ValueObjects
433
+ 8. ValueObjects are always immutable
253
434
 
254
- **Skip UseCase when:**
255
- - Simple CRUD with no business logic
256
- - Direct model query in controller is clearer
435
+ ## Check Scripts
257
436
 
258
- ## Testing
437
+ ```bash
438
+ # Run tests
439
+ php artisan test
440
+
441
+ # Static analysis on domain layer
442
+ phpstan analyse app/Domain/ --level=max
443
+
444
+ # Verify no forbidden imports in domain
445
+ grep -rn "Illuminate\\\\Database\|Illuminate\\\\Http\\\\Request\|Illuminate\\\\Support\\\\Facades" app/Domain/
446
+ # Expected: no output (zero matches)
447
+
448
+ # Run full test suite
449
+ php artisan test --parallel
450
+ ```
451
+
452
+ ## Test Patterns
453
+
454
+ ### Unit Test for UseCase (mock ports)
259
455
 
260
456
  ```php
261
457
  <?php
@@ -263,41 +459,77 @@ class Filter
263
459
 
264
460
  namespace Tests\Unit\Domain\Story;
265
461
 
266
- use Tests\TestCase;
267
- use App\Domain\Story\UseCase\GetStoryBySlugUseCase;
268
- use App\Domain\Story\Exceptions\StoryNotFound;
269
- use App\Models\Story;
270
- use App\Services\Cache\CacheService;
462
+ use PHPUnit\Framework\TestCase;
463
+ use App\Domain\Story\UseCases\GetStoryBySlugUseCase;
464
+ use App\Domain\Story\Ports\StoryStorePort;
465
+ use App\Domain\Story\Entities\Story;
271
466
  use Mockery;
272
467
 
273
468
  class GetStoryBySlugUseCaseTest extends TestCase
274
469
  {
275
470
  public function test_returns_story_when_found(): void
276
471
  {
277
- $story = Story::factory()->create(['slug' => 'test-story']);
278
- $cacheService = Mockery::mock(CacheService::class);
279
- $cacheService->shouldReceive('get')->andReturn(null);
280
- $cacheService->shouldReceive('put');
472
+ $story = Story::create('Test Story', 'Description', 'author-1');
473
+
474
+ $store = Mockery::mock(StoryStorePort::class);
475
+ $store->shouldReceive('findBySlug')
476
+ ->with('test-story')
477
+ ->andReturn($story);
281
478
 
282
- $useCase = new GetStoryBySlugUseCase(new Story(), $cacheService);
479
+ $useCase = new GetStoryBySlugUseCase($store);
283
480
  $result = $useCase->execute('test-story');
284
481
 
285
- $this->assertEquals($story->id, $result->id);
482
+ $this->assertEquals($story, $result);
286
483
  }
287
484
 
288
485
  public function test_throws_exception_when_not_found(): void
289
486
  {
290
- $this->expectException(StoryNotFound::class);
487
+ $this->expectException(\DomainException::class);
291
488
 
292
- $cacheService = Mockery::mock(CacheService::class);
293
- $cacheService->shouldReceive('get')->andReturn(null);
489
+ $store = Mockery::mock(StoryStorePort::class);
490
+ $store->shouldReceive('findBySlug')
491
+ ->with('non-existent')
492
+ ->andReturn(null);
294
493
 
295
- $useCase = new GetStoryBySlugUseCase(new Story(), $cacheService);
296
- $useCase->execute('non-existent-slug');
494
+ $useCase = new GetStoryBySlugUseCase($store);
495
+ $useCase->execute('non-existent');
496
+ }
497
+
498
+ protected function tearDown(): void
499
+ {
500
+ Mockery::close();
297
501
  }
298
502
  }
299
503
  ```
300
- # Documentation
301
504
 
302
- - When you create a new UseCase, make sure to document it in the project dir `docs/{domain}/{usecase}.md` file.
303
- - The usecase spectation documentation flow UML or Cockburn
505
+ ### Pest Alternative
506
+
507
+ ```php
508
+ <?php
509
+ // tests/Unit/Domain/Story/PublishStoryUseCaseTest.php
510
+
511
+ use App\Domain\Story\Entities\Story;
512
+ use App\Domain\Story\ValueObjects\StoryStatus;
513
+
514
+ it('publishes a draft story', function () {
515
+ $story = Story::create('Test', 'Desc', 'author-1');
516
+
517
+ $story->publish();
518
+
519
+ expect($story->status)->toBe(StoryStatus::PUBLISHED);
520
+ });
521
+
522
+ it('rejects publishing a non-draft story', function () {
523
+ $story = Story::create('Test', 'Desc', 'author-1');
524
+ $story->publish();
525
+
526
+ $story->publish();
527
+ })->throws(\DomainException::class);
528
+ ```
529
+
530
+ ## Documentation
531
+
532
+ When creating a new UseCase, document it in `docs/{domain}/{usecase}.md` with:
533
+ - Purpose and business rule description
534
+ - Input/output specification
535
+ - UML sequence diagram or Cockburn use case format