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.
- package/README.md +2 -1
- package/assets/architecture/ddd-architecture.md +337 -0
- package/assets/architecture/go-backend.md +770 -693
- package/assets/architecture/laravel-backend.md +388 -156
- package/assets/skills/architect-review/SKILL.md +292 -372
- package/assets/skills/deep-debug/SKILL.md +114 -0
- package/assets/skills/new-feature/SKILL.md +232 -252
- package/assets/skills/refactor/SKILL.md +261 -679
- package/assets/skills/sync-docs/SKILL.md +575 -0
- package/assets/templates/go-gin/CLAUDE.md +671 -121
- package/package.json +1 -1
- package/assets/architecture/clean-architecture.md +0 -143
- package/assets/skills/go-module/SKILL.md +0 -77
- package/assets/skills/ship/SKILL.md +0 -297
|
@@ -1,62 +1,108 @@
|
|
|
1
1
|
# Laravel Backend Structure
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Domain-Driven Design (DDD) Architecture for Laravel
|
|
4
4
|
|
|
5
|
-
##
|
|
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
|
-
|
|
9
|
-
├──
|
|
10
|
-
│
|
|
11
|
-
│
|
|
12
|
-
│
|
|
13
|
-
│
|
|
14
|
-
│
|
|
15
|
-
│
|
|
16
|
-
│
|
|
17
|
-
│
|
|
18
|
-
│
|
|
19
|
-
│
|
|
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
|
-
│ │
|
|
22
|
-
│ │
|
|
23
|
-
│
|
|
24
|
-
│ │
|
|
25
|
-
│
|
|
26
|
-
│
|
|
27
|
-
│
|
|
28
|
-
|
|
29
|
-
│
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
57
|
+
routes/
|
|
36
58
|
│ ├── api.php
|
|
37
59
|
│ └── web.php
|
|
38
|
-
|
|
39
|
-
│ ├──
|
|
40
|
-
│ └──
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
##
|
|
91
|
+
## Forbidden Imports in Domain Layer
|
|
92
|
+
|
|
93
|
+
These imports must NEVER appear in `app/Domain/`:
|
|
47
94
|
|
|
48
95
|
```
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
96
|
+
Illuminate\Database\*
|
|
97
|
+
Illuminate\Http\Request
|
|
98
|
+
Illuminate\Support\Facades\*
|
|
99
|
+
Illuminate\Cache\*
|
|
100
|
+
Illuminate\Queue\*
|
|
52
101
|
```
|
|
53
102
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
│ │ └──
|
|
70
|
-
│ ├──
|
|
71
|
-
│ │ ├──
|
|
72
|
-
│ │
|
|
73
|
-
│ ├──
|
|
74
|
-
│ │
|
|
75
|
-
│
|
|
76
|
-
│
|
|
77
|
-
│
|
|
78
|
-
│
|
|
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
|
-
│
|
|
131
|
+
│ ├── Entities/
|
|
132
|
+
│ │ └── User.php
|
|
133
|
+
│ ├── Ports/
|
|
134
|
+
│ │ └── UserStorePort.php
|
|
135
|
+
│ └── UseCases/
|
|
83
136
|
│ ├── GetUserUseCase.php
|
|
84
137
|
│ └── CreateUserUseCase.php
|
|
85
138
|
└── Shared/
|
|
86
|
-
└──
|
|
139
|
+
└── ValueObjects/
|
|
87
140
|
├── Filter.php
|
|
88
141
|
└── Sorter.php
|
|
89
142
|
```
|
|
90
143
|
|
|
91
144
|
## Key Files
|
|
92
145
|
|
|
93
|
-
###
|
|
146
|
+
### Port Interface
|
|
94
147
|
|
|
95
148
|
```php
|
|
96
149
|
<?php
|
|
97
|
-
// app/Domain/Story/
|
|
150
|
+
// app/Domain/Story/Ports/StoryStorePort.php
|
|
98
151
|
|
|
99
|
-
namespace App\Domain\Story\
|
|
152
|
+
namespace App\Domain\Story\Ports;
|
|
100
153
|
|
|
101
|
-
use App\Domain\Story\
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
109
|
-
protected CacheService $cacheService
|
|
244
|
+
private StoryStorePort $storyStore,
|
|
110
245
|
) {}
|
|
111
246
|
|
|
112
|
-
public function execute(string $slug):
|
|
247
|
+
public function execute(string $slug): Story
|
|
113
248
|
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
|
|
249
|
+
$story = $this->storyStore->findBySlug($slug);
|
|
250
|
+
|
|
251
|
+
if (!$story) {
|
|
252
|
+
throw new \DomainException("Story '{$slug}' not found");
|
|
118
253
|
}
|
|
119
254
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
->
|
|
280
|
+
->where('status', StoryStatus::PUBLISHED->value)
|
|
126
281
|
->first();
|
|
127
282
|
|
|
128
|
-
if (!$
|
|
129
|
-
|
|
283
|
+
if (!$model) {
|
|
284
|
+
return null;
|
|
130
285
|
}
|
|
131
286
|
|
|
132
|
-
|
|
133
|
-
|
|
287
|
+
return $this->toEntity($model);
|
|
288
|
+
}
|
|
134
289
|
|
|
135
|
-
|
|
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
|
|
329
|
+
### Application Controller
|
|
141
330
|
|
|
142
331
|
```php
|
|
143
332
|
<?php
|
|
144
|
-
// app/Http/Controllers/
|
|
333
|
+
// app/Application/Http/Controllers/StoryController.php
|
|
145
334
|
|
|
146
|
-
namespace App\Http\Controllers
|
|
335
|
+
namespace App\Application\Http\Controllers;
|
|
147
336
|
|
|
148
|
-
use App\Domain\Story\
|
|
149
|
-
use App\Domain\Story\
|
|
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
|
-
|
|
344
|
+
private GetStoryBySlugUseCase $getStoryBySlug,
|
|
345
|
+
private GetAllStoriesUseCase $getAllStories,
|
|
158
346
|
) {}
|
|
159
347
|
|
|
160
|
-
public function index(
|
|
161
|
-
|
|
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 = $
|
|
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->
|
|
363
|
+
$story = $this->getStoryBySlug->execute($slug);
|
|
179
364
|
return view('pages.story.show', compact('story'));
|
|
180
|
-
} catch (
|
|
365
|
+
} catch (\DomainException $e) {
|
|
181
366
|
abort(404);
|
|
182
367
|
}
|
|
183
368
|
}
|
|
184
369
|
}
|
|
185
370
|
```
|
|
186
371
|
|
|
187
|
-
###
|
|
372
|
+
### Wiring: ServiceProvider
|
|
188
373
|
|
|
189
374
|
```php
|
|
190
375
|
<?php
|
|
191
|
-
// app/
|
|
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
|
-
|
|
206
|
-
<?php
|
|
207
|
-
// app/Domain/Shared/Payload/Filter.php
|
|
378
|
+
namespace App\Providers;
|
|
208
379
|
|
|
209
|
-
|
|
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
|
|
386
|
+
class DomainServiceProvider extends ServiceProvider
|
|
212
387
|
{
|
|
213
|
-
public function
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 | `
|
|
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
|
-
##
|
|
424
|
+
## Hard Rules
|
|
247
425
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
- Simple CRUD with no business logic
|
|
256
|
-
- Direct model query in controller is clearer
|
|
435
|
+
## Check Scripts
|
|
257
436
|
|
|
258
|
-
|
|
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
|
|
267
|
-
use App\Domain\Story\
|
|
268
|
-
use App\Domain\Story\
|
|
269
|
-
use App\
|
|
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::
|
|
278
|
-
|
|
279
|
-
$
|
|
280
|
-
$
|
|
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(
|
|
479
|
+
$useCase = new GetStoryBySlugUseCase($store);
|
|
283
480
|
$result = $useCase->execute('test-story');
|
|
284
481
|
|
|
285
|
-
$this->assertEquals($story
|
|
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(
|
|
487
|
+
$this->expectException(\DomainException::class);
|
|
291
488
|
|
|
292
|
-
$
|
|
293
|
-
$
|
|
489
|
+
$store = Mockery::mock(StoryStorePort::class);
|
|
490
|
+
$store->shouldReceive('findBySlug')
|
|
491
|
+
->with('non-existent')
|
|
492
|
+
->andReturn(null);
|
|
294
493
|
|
|
295
|
-
$useCase = new GetStoryBySlugUseCase(
|
|
296
|
-
$useCase->execute('non-existent
|
|
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
|
-
|
|
303
|
-
|
|
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
|