omgkit 2.1.1 → 2.2.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/package.json +1 -1
- package/plugin/skills/SKILL_STANDARDS.md +743 -0
- package/plugin/skills/databases/mongodb/SKILL.md +797 -28
- package/plugin/skills/databases/prisma/SKILL.md +776 -30
- package/plugin/skills/databases/redis/SKILL.md +885 -25
- package/plugin/skills/devops/aws/SKILL.md +686 -28
- package/plugin/skills/devops/github-actions/SKILL.md +684 -29
- package/plugin/skills/devops/kubernetes/SKILL.md +621 -24
- package/plugin/skills/frameworks/django/SKILL.md +920 -20
- package/plugin/skills/frameworks/express/SKILL.md +1361 -35
- package/plugin/skills/frameworks/fastapi/SKILL.md +1260 -33
- package/plugin/skills/frameworks/laravel/SKILL.md +1244 -31
- package/plugin/skills/frameworks/nestjs/SKILL.md +1005 -26
- package/plugin/skills/frameworks/rails/SKILL.md +594 -28
- package/plugin/skills/frameworks/spring/SKILL.md +528 -35
- package/plugin/skills/frameworks/vue/SKILL.md +1296 -27
- package/plugin/skills/frontend/accessibility/SKILL.md +1108 -34
- package/plugin/skills/frontend/frontend-design/SKILL.md +1304 -26
- package/plugin/skills/frontend/responsive/SKILL.md +847 -21
- package/plugin/skills/frontend/shadcn-ui/SKILL.md +976 -38
- package/plugin/skills/frontend/tailwindcss/SKILL.md +831 -35
- package/plugin/skills/frontend/threejs/SKILL.md +1298 -29
- package/plugin/skills/languages/javascript/SKILL.md +935 -31
- package/plugin/skills/methodology/brainstorming/SKILL.md +597 -23
- package/plugin/skills/methodology/defense-in-depth/SKILL.md +832 -34
- package/plugin/skills/methodology/dispatching-parallel-agents/SKILL.md +665 -31
- package/plugin/skills/methodology/executing-plans/SKILL.md +556 -24
- package/plugin/skills/methodology/finishing-development-branch/SKILL.md +595 -25
- package/plugin/skills/methodology/problem-solving/SKILL.md +429 -61
- package/plugin/skills/methodology/receiving-code-review/SKILL.md +536 -24
- package/plugin/skills/methodology/requesting-code-review/SKILL.md +632 -21
- package/plugin/skills/methodology/root-cause-tracing/SKILL.md +641 -30
- package/plugin/skills/methodology/sequential-thinking/SKILL.md +262 -3
- package/plugin/skills/methodology/systematic-debugging/SKILL.md +571 -32
- package/plugin/skills/methodology/test-driven-development/SKILL.md +779 -24
- package/plugin/skills/methodology/testing-anti-patterns/SKILL.md +691 -29
- package/plugin/skills/methodology/token-optimization/SKILL.md +598 -29
- package/plugin/skills/methodology/verification-before-completion/SKILL.md +543 -22
- package/plugin/skills/methodology/writing-plans/SKILL.md +590 -18
- package/plugin/skills/omega/omega-architecture/SKILL.md +838 -39
- package/plugin/skills/omega/omega-coding/SKILL.md +636 -39
- package/plugin/skills/omega/omega-sprint/SKILL.md +855 -48
- package/plugin/skills/omega/omega-testing/SKILL.md +940 -41
- package/plugin/skills/omega/omega-thinking/SKILL.md +703 -50
- package/plugin/skills/security/better-auth/SKILL.md +1065 -28
- package/plugin/skills/security/oauth/SKILL.md +968 -31
- package/plugin/skills/security/owasp/SKILL.md +894 -33
- package/plugin/skills/testing/playwright/SKILL.md +764 -38
- package/plugin/skills/testing/pytest/SKILL.md +873 -36
- package/plugin/skills/testing/vitest/SKILL.md +980 -35
|
@@ -1,65 +1,1278 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: laravel
|
|
3
|
-
description: Laravel
|
|
3
|
+
description: Enterprise Laravel development with Eloquent, API resources, testing, and production patterns
|
|
4
|
+
category: frameworks
|
|
5
|
+
triggers:
|
|
6
|
+
- laravel
|
|
7
|
+
- php framework
|
|
8
|
+
- eloquent
|
|
9
|
+
- blade
|
|
10
|
+
- artisan
|
|
11
|
+
- php api
|
|
12
|
+
- laravel api
|
|
13
|
+
- lumen
|
|
4
14
|
---
|
|
5
15
|
|
|
6
|
-
# Laravel
|
|
16
|
+
# Laravel
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
Enterprise-grade **Laravel development** following industry best practices. This skill covers Eloquent ORM, API resources, service patterns, authentication, testing, queues, and production deployment configurations used by top engineering teams.
|
|
19
|
+
|
|
20
|
+
## Purpose
|
|
21
|
+
|
|
22
|
+
Build robust PHP applications with confidence:
|
|
23
|
+
|
|
24
|
+
- Design clean model architectures with Eloquent
|
|
25
|
+
- Implement REST APIs with API Resources
|
|
26
|
+
- Use service and repository patterns
|
|
27
|
+
- Handle authentication with Laravel Sanctum
|
|
28
|
+
- Write comprehensive tests with PHPUnit
|
|
29
|
+
- Deploy production-ready applications
|
|
30
|
+
- Leverage queues for background processing
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
### 1. Model Design and Relationships
|
|
9
35
|
|
|
10
|
-
### Model
|
|
11
36
|
```php
|
|
12
|
-
|
|
37
|
+
<?php
|
|
38
|
+
// app/Models/User.php
|
|
39
|
+
namespace App\Models;
|
|
40
|
+
|
|
41
|
+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
42
|
+
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
43
|
+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
44
|
+
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
45
|
+
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
46
|
+
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
47
|
+
use Illuminate\Notifications\Notifiable;
|
|
48
|
+
use Laravel\Sanctum\HasApiTokens;
|
|
49
|
+
|
|
50
|
+
class User extends Authenticatable
|
|
51
|
+
{
|
|
52
|
+
use HasApiTokens, HasFactory, Notifiable, HasUuids, SoftDeletes;
|
|
53
|
+
|
|
54
|
+
protected $fillable = [
|
|
55
|
+
'name',
|
|
56
|
+
'email',
|
|
57
|
+
'password',
|
|
58
|
+
'role',
|
|
59
|
+
'is_active',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
protected $hidden = [
|
|
63
|
+
'password',
|
|
64
|
+
'remember_token',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
protected $casts = [
|
|
68
|
+
'email_verified_at' => 'datetime',
|
|
69
|
+
'password' => 'hashed',
|
|
70
|
+
'is_active' => 'boolean',
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
protected $attributes = [
|
|
74
|
+
'role' => 'user',
|
|
75
|
+
'is_active' => true,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// Relationships
|
|
79
|
+
public function organizations(): BelongsToMany
|
|
80
|
+
{
|
|
81
|
+
return $this->belongsToMany(Organization::class, 'memberships')
|
|
82
|
+
->withPivot('role')
|
|
83
|
+
->withTimestamps();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public function ownedOrganizations(): HasMany
|
|
87
|
+
{
|
|
88
|
+
return $this->hasMany(Organization::class, 'owner_id');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public function projects(): HasMany
|
|
92
|
+
{
|
|
93
|
+
return $this->hasMany(Project::class, 'created_by');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Scopes
|
|
97
|
+
public function scopeActive($query)
|
|
98
|
+
{
|
|
99
|
+
return $query->where('is_active', true);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public function scopeRole($query, string $role)
|
|
103
|
+
{
|
|
104
|
+
return $query->where('role', $role);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public function scopeSearch($query, ?string $search)
|
|
108
|
+
{
|
|
109
|
+
if (!$search) {
|
|
110
|
+
return $query;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return $query->where(function ($q) use ($search) {
|
|
114
|
+
$q->where('name', 'like', "%{$search}%")
|
|
115
|
+
->orWhere('email', 'like', "%{$search}%");
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Accessors & Mutators
|
|
120
|
+
protected function name(): Attribute
|
|
121
|
+
{
|
|
122
|
+
return Attribute::make(
|
|
123
|
+
get: fn (string $value) => ucwords($value),
|
|
124
|
+
set: fn (string $value) => strtolower($value),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Methods
|
|
129
|
+
public function isAdmin(): bool
|
|
130
|
+
{
|
|
131
|
+
return $this->role === 'admin';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public function belongsToOrganization(Organization $organization): bool
|
|
135
|
+
{
|
|
136
|
+
return $this->organizations()->where('organizations.id', $organization->id)->exists();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
// app/Models/Organization.php
|
|
142
|
+
namespace App\Models;
|
|
143
|
+
|
|
144
|
+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
145
|
+
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
146
|
+
use Illuminate\Database\Eloquent\Model;
|
|
147
|
+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
148
|
+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
149
|
+
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
150
|
+
|
|
151
|
+
class Organization extends Model
|
|
152
|
+
{
|
|
153
|
+
use HasFactory, HasUuids;
|
|
154
|
+
|
|
155
|
+
protected $fillable = [
|
|
156
|
+
'name',
|
|
157
|
+
'slug',
|
|
158
|
+
'owner_id',
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
// Relationships
|
|
162
|
+
public function owner(): BelongsTo
|
|
163
|
+
{
|
|
164
|
+
return $this->belongsTo(User::class, 'owner_id');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
public function members(): BelongsToMany
|
|
168
|
+
{
|
|
169
|
+
return $this->belongsToMany(User::class, 'memberships')
|
|
170
|
+
->withPivot('role')
|
|
171
|
+
->withTimestamps();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public function projects(): HasMany
|
|
175
|
+
{
|
|
176
|
+
return $this->hasMany(Project::class);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Scopes
|
|
180
|
+
public function scopeForUser($query, User $user)
|
|
181
|
+
{
|
|
182
|
+
return $query->whereHas('members', fn ($q) => $q->where('users.id', $user->id));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
// app/Models/Project.php
|
|
188
|
+
namespace App\Models;
|
|
189
|
+
|
|
190
|
+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
191
|
+
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
192
|
+
use Illuminate\Database\Eloquent\Model;
|
|
193
|
+
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
194
|
+
|
|
195
|
+
class Project extends Model
|
|
196
|
+
{
|
|
197
|
+
use HasFactory, HasUuids, SoftDeletes;
|
|
198
|
+
|
|
199
|
+
protected $fillable = [
|
|
200
|
+
'organization_id',
|
|
201
|
+
'name',
|
|
202
|
+
'description',
|
|
203
|
+
'status',
|
|
204
|
+
'created_by',
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
protected $casts = [
|
|
208
|
+
'status' => ProjectStatus::class,
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
protected $attributes = [
|
|
212
|
+
'status' => ProjectStatus::DRAFT,
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
// Relationships
|
|
216
|
+
public function organization()
|
|
217
|
+
{
|
|
218
|
+
return $this->belongsTo(Organization::class);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
public function creator()
|
|
222
|
+
{
|
|
223
|
+
return $this->belongsTo(User::class, 'created_by');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public function tasks()
|
|
227
|
+
{
|
|
228
|
+
return $this->hasMany(Task::class);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Scopes
|
|
232
|
+
public function scopeActive($query)
|
|
233
|
+
{
|
|
234
|
+
return $query->where('status', ProjectStatus::ACTIVE);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
public function scopeForOrganization($query, $organizationId)
|
|
238
|
+
{
|
|
239
|
+
return $query->where('organization_id', $organizationId);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
// app/Enums/ProjectStatus.php
|
|
245
|
+
namespace App\Enums;
|
|
246
|
+
|
|
247
|
+
enum ProjectStatus: string
|
|
13
248
|
{
|
|
14
|
-
|
|
15
|
-
|
|
249
|
+
case DRAFT = 'draft';
|
|
250
|
+
case ACTIVE = 'active';
|
|
251
|
+
case COMPLETED = 'completed';
|
|
252
|
+
case ARCHIVED = 'archived';
|
|
16
253
|
|
|
17
|
-
public function
|
|
254
|
+
public function label(): string
|
|
18
255
|
{
|
|
19
|
-
return $this
|
|
256
|
+
return match ($this) {
|
|
257
|
+
self::DRAFT => 'Draft',
|
|
258
|
+
self::ACTIVE => 'Active',
|
|
259
|
+
self::COMPLETED => 'Completed',
|
|
260
|
+
self::ARCHIVED => 'Archived',
|
|
261
|
+
};
|
|
20
262
|
}
|
|
21
263
|
}
|
|
22
264
|
```
|
|
23
265
|
|
|
24
|
-
###
|
|
266
|
+
### 2. API Resources and Collections
|
|
267
|
+
|
|
25
268
|
```php
|
|
269
|
+
<?php
|
|
270
|
+
// app/Http/Resources/UserResource.php
|
|
271
|
+
namespace App\Http\Resources;
|
|
272
|
+
|
|
273
|
+
use Illuminate\Http\Request;
|
|
274
|
+
use Illuminate\Http\Resources\Json\JsonResource;
|
|
275
|
+
|
|
276
|
+
class UserResource extends JsonResource
|
|
277
|
+
{
|
|
278
|
+
public function toArray(Request $request): array
|
|
279
|
+
{
|
|
280
|
+
return [
|
|
281
|
+
'id' => $this->id,
|
|
282
|
+
'name' => $this->name,
|
|
283
|
+
'email' => $this->email,
|
|
284
|
+
'role' => $this->role,
|
|
285
|
+
'is_active' => $this->is_active,
|
|
286
|
+
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
|
|
287
|
+
'created_at' => $this->created_at->toIso8601String(),
|
|
288
|
+
'updated_at' => $this->updated_at->toIso8601String(),
|
|
289
|
+
|
|
290
|
+
// Conditional relationships
|
|
291
|
+
'organizations' => OrganizationResource::collection(
|
|
292
|
+
$this->whenLoaded('organizations')
|
|
293
|
+
),
|
|
294
|
+
'organization_count' => $this->when(
|
|
295
|
+
$this->organizations_count !== null,
|
|
296
|
+
$this->organizations_count
|
|
297
|
+
),
|
|
298
|
+
];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
// app/Http/Resources/OrganizationResource.php
|
|
304
|
+
namespace App\Http\Resources;
|
|
305
|
+
|
|
306
|
+
use Illuminate\Http\Request;
|
|
307
|
+
use Illuminate\Http\Resources\Json\JsonResource;
|
|
308
|
+
|
|
309
|
+
class OrganizationResource extends JsonResource
|
|
310
|
+
{
|
|
311
|
+
public function toArray(Request $request): array
|
|
312
|
+
{
|
|
313
|
+
return [
|
|
314
|
+
'id' => $this->id,
|
|
315
|
+
'name' => $this->name,
|
|
316
|
+
'slug' => $this->slug,
|
|
317
|
+
'owner' => new UserResource($this->whenLoaded('owner')),
|
|
318
|
+
'member_count' => $this->when(
|
|
319
|
+
$this->members_count !== null,
|
|
320
|
+
$this->members_count
|
|
321
|
+
),
|
|
322
|
+
'created_at' => $this->created_at->toIso8601String(),
|
|
323
|
+
|
|
324
|
+
// Pivot data when loaded through relationship
|
|
325
|
+
'membership' => $this->when($this->pivot, [
|
|
326
|
+
'role' => $this->pivot?->role,
|
|
327
|
+
'joined_at' => $this->pivot?->created_at?->toIso8601String(),
|
|
328
|
+
]),
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
// app/Http/Resources/ProjectResource.php
|
|
335
|
+
namespace App\Http\Resources;
|
|
336
|
+
|
|
337
|
+
use Illuminate\Http\Request;
|
|
338
|
+
use Illuminate\Http\Resources\Json\JsonResource;
|
|
339
|
+
|
|
340
|
+
class ProjectResource extends JsonResource
|
|
341
|
+
{
|
|
342
|
+
public function toArray(Request $request): array
|
|
343
|
+
{
|
|
344
|
+
return [
|
|
345
|
+
'id' => $this->id,
|
|
346
|
+
'name' => $this->name,
|
|
347
|
+
'description' => $this->description,
|
|
348
|
+
'status' => [
|
|
349
|
+
'value' => $this->status->value,
|
|
350
|
+
'label' => $this->status->label(),
|
|
351
|
+
],
|
|
352
|
+
'organization' => new OrganizationResource($this->whenLoaded('organization')),
|
|
353
|
+
'creator' => new UserResource($this->whenLoaded('creator')),
|
|
354
|
+
'task_count' => $this->when(
|
|
355
|
+
$this->tasks_count !== null,
|
|
356
|
+
$this->tasks_count
|
|
357
|
+
),
|
|
358
|
+
'created_at' => $this->created_at->toIso8601String(),
|
|
359
|
+
'updated_at' => $this->updated_at->toIso8601String(),
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
// app/Http/Resources/PaginatedCollection.php
|
|
366
|
+
namespace App\Http\Resources;
|
|
367
|
+
|
|
368
|
+
use Illuminate\Http\Request;
|
|
369
|
+
use Illuminate\Http\Resources\Json\ResourceCollection;
|
|
370
|
+
|
|
371
|
+
class PaginatedCollection extends ResourceCollection
|
|
372
|
+
{
|
|
373
|
+
protected string $resourceClass;
|
|
374
|
+
|
|
375
|
+
public function __construct($resource, string $resourceClass)
|
|
376
|
+
{
|
|
377
|
+
parent::__construct($resource);
|
|
378
|
+
$this->resourceClass = $resourceClass;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
public function toArray(Request $request): array
|
|
382
|
+
{
|
|
383
|
+
return [
|
|
384
|
+
'data' => $this->resourceClass::collection($this->collection),
|
|
385
|
+
'pagination' => [
|
|
386
|
+
'current_page' => $this->currentPage(),
|
|
387
|
+
'per_page' => $this->perPage(),
|
|
388
|
+
'total' => $this->total(),
|
|
389
|
+
'total_pages' => $this->lastPage(),
|
|
390
|
+
'has_more' => $this->hasMorePages(),
|
|
391
|
+
],
|
|
392
|
+
];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### 3. Form Requests and Validation
|
|
398
|
+
|
|
399
|
+
```php
|
|
400
|
+
<?php
|
|
401
|
+
// app/Http/Requests/User/CreateUserRequest.php
|
|
402
|
+
namespace App\Http\Requests\User;
|
|
403
|
+
|
|
404
|
+
use Illuminate\Foundation\Http\FormRequest;
|
|
405
|
+
use Illuminate\Validation\Rules\Password;
|
|
406
|
+
|
|
407
|
+
class CreateUserRequest extends FormRequest
|
|
408
|
+
{
|
|
409
|
+
public function authorize(): bool
|
|
410
|
+
{
|
|
411
|
+
return $this->user()->isAdmin();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
public function rules(): array
|
|
415
|
+
{
|
|
416
|
+
return [
|
|
417
|
+
'name' => ['required', 'string', 'min:2', 'max:100'],
|
|
418
|
+
'email' => ['required', 'email', 'unique:users,email'],
|
|
419
|
+
'password' => [
|
|
420
|
+
'required',
|
|
421
|
+
'confirmed',
|
|
422
|
+
Password::min(8)
|
|
423
|
+
->mixedCase()
|
|
424
|
+
->numbers()
|
|
425
|
+
->symbols(),
|
|
426
|
+
],
|
|
427
|
+
'role' => ['sometimes', 'string', 'in:admin,user,guest'],
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
public function messages(): array
|
|
432
|
+
{
|
|
433
|
+
return [
|
|
434
|
+
'email.unique' => 'This email is already registered.',
|
|
435
|
+
'password.confirmed' => 'Password confirmation does not match.',
|
|
436
|
+
];
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
// app/Http/Requests/User/UpdateUserRequest.php
|
|
442
|
+
namespace App\Http\Requests\User;
|
|
443
|
+
|
|
444
|
+
use Illuminate\Foundation\Http\FormRequest;
|
|
445
|
+
use Illuminate\Validation\Rule;
|
|
446
|
+
|
|
447
|
+
class UpdateUserRequest extends FormRequest
|
|
448
|
+
{
|
|
449
|
+
public function authorize(): bool
|
|
450
|
+
{
|
|
451
|
+
return $this->user()->isAdmin() || $this->user()->id === $this->route('user')->id;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
public function rules(): array
|
|
455
|
+
{
|
|
456
|
+
return [
|
|
457
|
+
'name' => ['sometimes', 'string', 'min:2', 'max:100'],
|
|
458
|
+
'email' => [
|
|
459
|
+
'sometimes',
|
|
460
|
+
'email',
|
|
461
|
+
Rule::unique('users')->ignore($this->route('user')),
|
|
462
|
+
],
|
|
463
|
+
'is_active' => ['sometimes', 'boolean'],
|
|
464
|
+
];
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
// app/Http/Requests/Organization/CreateOrganizationRequest.php
|
|
470
|
+
namespace App\Http\Requests\Organization;
|
|
471
|
+
|
|
472
|
+
use Illuminate\Foundation\Http\FormRequest;
|
|
473
|
+
|
|
474
|
+
class CreateOrganizationRequest extends FormRequest
|
|
475
|
+
{
|
|
476
|
+
public function authorize(): bool
|
|
477
|
+
{
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
public function rules(): array
|
|
482
|
+
{
|
|
483
|
+
return [
|
|
484
|
+
'name' => ['required', 'string', 'min:2', 'max:255'],
|
|
485
|
+
'slug' => [
|
|
486
|
+
'required',
|
|
487
|
+
'string',
|
|
488
|
+
'min:2',
|
|
489
|
+
'max:100',
|
|
490
|
+
'regex:/^[a-z0-9-]+$/',
|
|
491
|
+
'unique:organizations,slug',
|
|
492
|
+
],
|
|
493
|
+
];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
public function messages(): array
|
|
497
|
+
{
|
|
498
|
+
return [
|
|
499
|
+
'slug.regex' => 'Slug must contain only lowercase letters, numbers, and hyphens.',
|
|
500
|
+
'slug.unique' => 'This slug is already taken.',
|
|
501
|
+
];
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
// app/Http/Requests/Project/CreateProjectRequest.php
|
|
507
|
+
namespace App\Http\Requests\Project;
|
|
508
|
+
|
|
509
|
+
use App\Enums\ProjectStatus;
|
|
510
|
+
use Illuminate\Foundation\Http\FormRequest;
|
|
511
|
+
use Illuminate\Validation\Rule;
|
|
512
|
+
|
|
513
|
+
class CreateProjectRequest extends FormRequest
|
|
514
|
+
{
|
|
515
|
+
public function authorize(): bool
|
|
516
|
+
{
|
|
517
|
+
$organization = $this->route('organization');
|
|
518
|
+
return $this->user()->belongsToOrganization($organization);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
public function rules(): array
|
|
522
|
+
{
|
|
523
|
+
return [
|
|
524
|
+
'name' => [
|
|
525
|
+
'required',
|
|
526
|
+
'string',
|
|
527
|
+
'min:1',
|
|
528
|
+
'max:255',
|
|
529
|
+
Rule::unique('projects')
|
|
530
|
+
->where('organization_id', $this->route('organization')->id),
|
|
531
|
+
],
|
|
532
|
+
'description' => ['nullable', 'string', 'max:5000'],
|
|
533
|
+
'status' => ['sometimes', Rule::enum(ProjectStatus::class)],
|
|
534
|
+
];
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### 4. Controllers
|
|
540
|
+
|
|
541
|
+
```php
|
|
542
|
+
<?php
|
|
543
|
+
// app/Http/Controllers/Api/UserController.php
|
|
544
|
+
namespace App\Http\Controllers\Api;
|
|
545
|
+
|
|
546
|
+
use App\Http\Controllers\Controller;
|
|
547
|
+
use App\Http\Requests\User\CreateUserRequest;
|
|
548
|
+
use App\Http\Requests\User\UpdateUserRequest;
|
|
549
|
+
use App\Http\Resources\PaginatedCollection;
|
|
550
|
+
use App\Http\Resources\UserResource;
|
|
551
|
+
use App\Models\User;
|
|
552
|
+
use App\Services\UserService;
|
|
553
|
+
use Illuminate\Http\JsonResponse;
|
|
554
|
+
use Illuminate\Http\Request;
|
|
555
|
+
use Illuminate\Http\Response;
|
|
556
|
+
|
|
26
557
|
class UserController extends Controller
|
|
27
558
|
{
|
|
28
|
-
public function
|
|
559
|
+
public function __construct(
|
|
560
|
+
private readonly UserService $userService
|
|
561
|
+
) {}
|
|
562
|
+
|
|
563
|
+
public function index(Request $request): PaginatedCollection
|
|
564
|
+
{
|
|
565
|
+
$users = $this->userService->list(
|
|
566
|
+
search: $request->input('search'),
|
|
567
|
+
role: $request->input('role'),
|
|
568
|
+
perPage: $request->input('per_page', 20)
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
return new PaginatedCollection($users, UserResource::class);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
public function show(User $user): UserResource
|
|
575
|
+
{
|
|
576
|
+
return new UserResource(
|
|
577
|
+
$user->load('organizations')
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
public function store(CreateUserRequest $request): JsonResponse
|
|
582
|
+
{
|
|
583
|
+
$user = $this->userService->create($request->validated());
|
|
584
|
+
|
|
585
|
+
return (new UserResource($user))
|
|
586
|
+
->response()
|
|
587
|
+
->setStatusCode(Response::HTTP_CREATED);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
public function update(UpdateUserRequest $request, User $user): UserResource
|
|
591
|
+
{
|
|
592
|
+
$user = $this->userService->update($user, $request->validated());
|
|
593
|
+
|
|
594
|
+
return new UserResource($user);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
public function destroy(User $user): Response
|
|
598
|
+
{
|
|
599
|
+
$this->userService->delete($user);
|
|
600
|
+
|
|
601
|
+
return response()->noContent();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
public function me(Request $request): UserResource
|
|
605
|
+
{
|
|
606
|
+
return new UserResource(
|
|
607
|
+
$request->user()->load('organizations')
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
// app/Http/Controllers/Api/AuthController.php
|
|
614
|
+
namespace App\Http\Controllers\Api;
|
|
615
|
+
|
|
616
|
+
use App\Http\Controllers\Controller;
|
|
617
|
+
use App\Http\Requests\Auth\LoginRequest;
|
|
618
|
+
use App\Http\Requests\Auth\RegisterRequest;
|
|
619
|
+
use App\Http\Resources\UserResource;
|
|
620
|
+
use App\Services\AuthService;
|
|
621
|
+
use Illuminate\Http\JsonResponse;
|
|
622
|
+
use Illuminate\Http\Request;
|
|
623
|
+
use Illuminate\Http\Response;
|
|
624
|
+
|
|
625
|
+
class AuthController extends Controller
|
|
626
|
+
{
|
|
627
|
+
public function __construct(
|
|
628
|
+
private readonly AuthService $authService
|
|
629
|
+
) {}
|
|
630
|
+
|
|
631
|
+
public function register(RegisterRequest $request): JsonResponse
|
|
632
|
+
{
|
|
633
|
+
$result = $this->authService->register($request->validated());
|
|
634
|
+
|
|
635
|
+
return response()->json([
|
|
636
|
+
'user' => new UserResource($result['user']),
|
|
637
|
+
'token' => $result['token'],
|
|
638
|
+
], Response::HTTP_CREATED);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
public function login(LoginRequest $request): JsonResponse
|
|
642
|
+
{
|
|
643
|
+
$result = $this->authService->login(
|
|
644
|
+
$request->input('email'),
|
|
645
|
+
$request->input('password')
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
if (!$result) {
|
|
649
|
+
return response()->json([
|
|
650
|
+
'message' => 'Invalid credentials',
|
|
651
|
+
], Response::HTTP_UNAUTHORIZED);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return response()->json([
|
|
655
|
+
'user' => new UserResource($result['user']),
|
|
656
|
+
'token' => $result['token'],
|
|
657
|
+
]);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
public function logout(Request $request): Response
|
|
661
|
+
{
|
|
662
|
+
$request->user()->currentAccessToken()->delete();
|
|
663
|
+
|
|
664
|
+
return response()->noContent();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
public function refresh(Request $request): JsonResponse
|
|
668
|
+
{
|
|
669
|
+
$token = $this->authService->refreshToken($request->user());
|
|
670
|
+
|
|
671
|
+
return response()->json([
|
|
672
|
+
'token' => $token,
|
|
673
|
+
]);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
// app/Http/Controllers/Api/ProjectController.php
|
|
679
|
+
namespace App\Http\Controllers\Api;
|
|
680
|
+
|
|
681
|
+
use App\Http\Controllers\Controller;
|
|
682
|
+
use App\Http\Requests\Project\CreateProjectRequest;
|
|
683
|
+
use App\Http\Requests\Project\UpdateProjectRequest;
|
|
684
|
+
use App\Http\Resources\PaginatedCollection;
|
|
685
|
+
use App\Http\Resources\ProjectResource;
|
|
686
|
+
use App\Models\Organization;
|
|
687
|
+
use App\Models\Project;
|
|
688
|
+
use App\Services\ProjectService;
|
|
689
|
+
use Illuminate\Http\JsonResponse;
|
|
690
|
+
use Illuminate\Http\Request;
|
|
691
|
+
use Illuminate\Http\Response;
|
|
692
|
+
|
|
693
|
+
class ProjectController extends Controller
|
|
694
|
+
{
|
|
695
|
+
public function __construct(
|
|
696
|
+
private readonly ProjectService $projectService
|
|
697
|
+
) {}
|
|
698
|
+
|
|
699
|
+
public function index(Request $request, Organization $organization): PaginatedCollection
|
|
700
|
+
{
|
|
701
|
+
$projects = $this->projectService->listForOrganization(
|
|
702
|
+
$organization,
|
|
703
|
+
status: $request->input('status'),
|
|
704
|
+
perPage: $request->input('per_page', 20)
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
return new PaginatedCollection($projects, ProjectResource::class);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
public function store(CreateProjectRequest $request, Organization $organization): JsonResponse
|
|
711
|
+
{
|
|
712
|
+
$project = $this->projectService->create(
|
|
713
|
+
$organization,
|
|
714
|
+
$request->user(),
|
|
715
|
+
$request->validated()
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
return (new ProjectResource($project))
|
|
719
|
+
->response()
|
|
720
|
+
->setStatusCode(Response::HTTP_CREATED);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
public function show(Organization $organization, Project $project): ProjectResource
|
|
724
|
+
{
|
|
725
|
+
return new ProjectResource(
|
|
726
|
+
$project->load(['organization', 'creator'])
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
public function update(
|
|
731
|
+
UpdateProjectRequest $request,
|
|
732
|
+
Organization $organization,
|
|
733
|
+
Project $project
|
|
734
|
+
): ProjectResource {
|
|
735
|
+
$project = $this->projectService->update($project, $request->validated());
|
|
736
|
+
|
|
737
|
+
return new ProjectResource($project);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
public function destroy(Organization $organization, Project $project): Response
|
|
741
|
+
{
|
|
742
|
+
$this->projectService->delete($project);
|
|
743
|
+
|
|
744
|
+
return response()->noContent();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
### 5. Service Layer
|
|
750
|
+
|
|
751
|
+
```php
|
|
752
|
+
<?php
|
|
753
|
+
// app/Services/UserService.php
|
|
754
|
+
namespace App\Services;
|
|
755
|
+
|
|
756
|
+
use App\Models\User;
|
|
757
|
+
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
758
|
+
use Illuminate\Support\Facades\Hash;
|
|
759
|
+
|
|
760
|
+
class UserService
|
|
761
|
+
{
|
|
762
|
+
public function list(
|
|
763
|
+
?string $search = null,
|
|
764
|
+
?string $role = null,
|
|
765
|
+
int $perPage = 20
|
|
766
|
+
): LengthAwarePaginator {
|
|
767
|
+
return User::query()
|
|
768
|
+
->active()
|
|
769
|
+
->search($search)
|
|
770
|
+
->when($role, fn ($q) => $q->role($role))
|
|
771
|
+
->withCount('organizations')
|
|
772
|
+
->orderByDesc('created_at')
|
|
773
|
+
->paginate($perPage);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
public function create(array $data): User
|
|
777
|
+
{
|
|
778
|
+
return User::create([
|
|
779
|
+
'name' => $data['name'],
|
|
780
|
+
'email' => $data['email'],
|
|
781
|
+
'password' => Hash::make($data['password']),
|
|
782
|
+
'role' => $data['role'] ?? 'user',
|
|
783
|
+
]);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
public function update(User $user, array $data): User
|
|
787
|
+
{
|
|
788
|
+
$user->update($data);
|
|
789
|
+
|
|
790
|
+
return $user->fresh();
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
public function delete(User $user): void
|
|
794
|
+
{
|
|
795
|
+
$user->delete(); // Soft delete
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
public function findByEmail(string $email): ?User
|
|
799
|
+
{
|
|
800
|
+
return User::where('email', $email)->first();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
// app/Services/AuthService.php
|
|
806
|
+
namespace App\Services;
|
|
807
|
+
|
|
808
|
+
use App\Models\User;
|
|
809
|
+
use Illuminate\Support\Facades\Hash;
|
|
810
|
+
|
|
811
|
+
class AuthService
|
|
812
|
+
{
|
|
813
|
+
public function __construct(
|
|
814
|
+
private readonly UserService $userService
|
|
815
|
+
) {}
|
|
816
|
+
|
|
817
|
+
public function register(array $data): array
|
|
818
|
+
{
|
|
819
|
+
$user = $this->userService->create($data);
|
|
820
|
+
$token = $user->createToken('auth-token')->plainTextToken;
|
|
821
|
+
|
|
822
|
+
return [
|
|
823
|
+
'user' => $user,
|
|
824
|
+
'token' => $token,
|
|
825
|
+
];
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
public function login(string $email, string $password): ?array
|
|
29
829
|
{
|
|
30
|
-
|
|
830
|
+
$user = $this->userService->findByEmail($email);
|
|
831
|
+
|
|
832
|
+
if (!$user || !Hash::check($password, $user->password)) {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (!$user->is_active) {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Revoke existing tokens
|
|
841
|
+
$user->tokens()->delete();
|
|
842
|
+
|
|
843
|
+
$token = $user->createToken('auth-token')->plainTextToken;
|
|
844
|
+
|
|
845
|
+
return [
|
|
846
|
+
'user' => $user,
|
|
847
|
+
'token' => $token,
|
|
848
|
+
];
|
|
31
849
|
}
|
|
32
850
|
|
|
33
|
-
public function
|
|
851
|
+
public function refreshToken(User $user): string
|
|
34
852
|
{
|
|
35
|
-
$
|
|
36
|
-
|
|
37
|
-
|
|
853
|
+
$user->currentAccessToken()->delete();
|
|
854
|
+
|
|
855
|
+
return $user->createToken('auth-token')->plainTextToken;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
// app/Services/ProjectService.php
|
|
861
|
+
namespace App\Services;
|
|
862
|
+
|
|
863
|
+
use App\Enums\ProjectStatus;
|
|
864
|
+
use App\Models\Organization;
|
|
865
|
+
use App\Models\Project;
|
|
866
|
+
use App\Models\User;
|
|
867
|
+
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
868
|
+
|
|
869
|
+
class ProjectService
|
|
870
|
+
{
|
|
871
|
+
public function listForOrganization(
|
|
872
|
+
Organization $organization,
|
|
873
|
+
?string $status = null,
|
|
874
|
+
int $perPage = 20
|
|
875
|
+
): LengthAwarePaginator {
|
|
876
|
+
return Project::query()
|
|
877
|
+
->forOrganization($organization->id)
|
|
878
|
+
->when($status, fn ($q) => $q->where('status', $status))
|
|
879
|
+
->with(['creator'])
|
|
880
|
+
->withCount('tasks')
|
|
881
|
+
->orderByDesc('created_at')
|
|
882
|
+
->paginate($perPage);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
public function create(
|
|
886
|
+
Organization $organization,
|
|
887
|
+
User $creator,
|
|
888
|
+
array $data
|
|
889
|
+
): Project {
|
|
890
|
+
return Project::create([
|
|
891
|
+
'organization_id' => $organization->id,
|
|
892
|
+
'created_by' => $creator->id,
|
|
893
|
+
'name' => $data['name'],
|
|
894
|
+
'description' => $data['description'] ?? null,
|
|
895
|
+
'status' => $data['status'] ?? ProjectStatus::DRAFT,
|
|
38
896
|
]);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
public function update(Project $project, array $data): Project
|
|
900
|
+
{
|
|
901
|
+
$project->update($data);
|
|
902
|
+
|
|
903
|
+
return $project->fresh();
|
|
904
|
+
}
|
|
39
905
|
|
|
40
|
-
|
|
41
|
-
|
|
906
|
+
public function delete(Project $project): void
|
|
907
|
+
{
|
|
908
|
+
$project->delete(); // Soft delete
|
|
42
909
|
}
|
|
43
910
|
}
|
|
44
911
|
```
|
|
45
912
|
|
|
46
|
-
###
|
|
913
|
+
### 6. Middleware and Policies
|
|
914
|
+
|
|
47
915
|
```php
|
|
48
|
-
|
|
916
|
+
<?php
|
|
917
|
+
// app/Http/Middleware/EnsureOrganizationMember.php
|
|
918
|
+
namespace App\Http\Middleware;
|
|
919
|
+
|
|
920
|
+
use App\Models\Organization;
|
|
921
|
+
use Closure;
|
|
922
|
+
use Illuminate\Http\Request;
|
|
923
|
+
use Symfony\Component\HttpFoundation\Response;
|
|
924
|
+
|
|
925
|
+
class EnsureOrganizationMember
|
|
926
|
+
{
|
|
927
|
+
public function handle(Request $request, Closure $next): Response
|
|
928
|
+
{
|
|
929
|
+
$organization = $request->route('organization');
|
|
930
|
+
|
|
931
|
+
if (!$organization instanceof Organization) {
|
|
932
|
+
abort(404, 'Organization not found');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (!$request->user()->belongsToOrganization($organization)) {
|
|
936
|
+
abort(403, 'You are not a member of this organization');
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return $next($request);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
// app/Policies/ProjectPolicy.php
|
|
945
|
+
namespace App\Policies;
|
|
946
|
+
|
|
947
|
+
use App\Models\Project;
|
|
948
|
+
use App\Models\User;
|
|
949
|
+
|
|
950
|
+
class ProjectPolicy
|
|
951
|
+
{
|
|
952
|
+
public function viewAny(User $user): bool
|
|
953
|
+
{
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
public function view(User $user, Project $project): bool
|
|
958
|
+
{
|
|
959
|
+
return $user->belongsToOrganization($project->organization);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
public function create(User $user): bool
|
|
963
|
+
{
|
|
964
|
+
return true;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
public function update(User $user, Project $project): bool
|
|
968
|
+
{
|
|
969
|
+
if ($user->isAdmin()) {
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return $user->id === $project->created_by;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
public function delete(User $user, Project $project): bool
|
|
977
|
+
{
|
|
978
|
+
if ($user->isAdmin()) {
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return $user->id === $project->created_by;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
49
985
|
```
|
|
50
986
|
|
|
51
|
-
###
|
|
987
|
+
### 7. Testing Patterns
|
|
988
|
+
|
|
52
989
|
```php
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
990
|
+
<?php
|
|
991
|
+
// tests/Feature/UserTest.php
|
|
992
|
+
namespace Tests\Feature;
|
|
993
|
+
|
|
994
|
+
use App\Models\User;
|
|
995
|
+
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
996
|
+
use Laravel\Sanctum\Sanctum;
|
|
997
|
+
use Tests\TestCase;
|
|
998
|
+
|
|
999
|
+
class UserTest extends TestCase
|
|
1000
|
+
{
|
|
1001
|
+
use RefreshDatabase;
|
|
1002
|
+
|
|
1003
|
+
public function test_admin_can_list_users(): void
|
|
1004
|
+
{
|
|
1005
|
+
$admin = User::factory()->create(['role' => 'admin']);
|
|
1006
|
+
User::factory()->count(5)->create();
|
|
1007
|
+
|
|
1008
|
+
Sanctum::actingAs($admin);
|
|
1009
|
+
|
|
1010
|
+
$response = $this->getJson('/api/users');
|
|
1011
|
+
|
|
1012
|
+
$response->assertOk()
|
|
1013
|
+
->assertJsonStructure([
|
|
1014
|
+
'data' => [
|
|
1015
|
+
'*' => ['id', 'name', 'email', 'role', 'created_at'],
|
|
1016
|
+
],
|
|
1017
|
+
'pagination' => ['current_page', 'total', 'per_page'],
|
|
1018
|
+
]);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
public function test_non_admin_cannot_list_users(): void
|
|
1022
|
+
{
|
|
1023
|
+
$user = User::factory()->create(['role' => 'user']);
|
|
1024
|
+
|
|
1025
|
+
Sanctum::actingAs($user);
|
|
1026
|
+
|
|
1027
|
+
$response = $this->getJson('/api/users');
|
|
1028
|
+
|
|
1029
|
+
$response->assertForbidden();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
public function test_user_can_get_own_profile(): void
|
|
1033
|
+
{
|
|
1034
|
+
$user = User::factory()->create();
|
|
1035
|
+
|
|
1036
|
+
Sanctum::actingAs($user);
|
|
1037
|
+
|
|
1038
|
+
$response = $this->getJson('/api/users/me');
|
|
1039
|
+
|
|
1040
|
+
$response->assertOk()
|
|
1041
|
+
->assertJson([
|
|
1042
|
+
'data' => [
|
|
1043
|
+
'id' => $user->id,
|
|
1044
|
+
'email' => $user->email,
|
|
1045
|
+
],
|
|
1046
|
+
]);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
public function test_admin_can_create_user(): void
|
|
1050
|
+
{
|
|
1051
|
+
$admin = User::factory()->create(['role' => 'admin']);
|
|
1052
|
+
|
|
1053
|
+
Sanctum::actingAs($admin);
|
|
1054
|
+
|
|
1055
|
+
$response = $this->postJson('/api/users', [
|
|
1056
|
+
'name' => 'New User',
|
|
1057
|
+
'email' => 'new@example.com',
|
|
1058
|
+
'password' => 'SecurePass123!',
|
|
1059
|
+
'password_confirmation' => 'SecurePass123!',
|
|
1060
|
+
]);
|
|
1061
|
+
|
|
1062
|
+
$response->assertCreated()
|
|
1063
|
+
->assertJson([
|
|
1064
|
+
'data' => [
|
|
1065
|
+
'email' => 'new@example.com',
|
|
1066
|
+
],
|
|
1067
|
+
]);
|
|
1068
|
+
|
|
1069
|
+
$this->assertDatabaseHas('users', [
|
|
1070
|
+
'email' => 'new@example.com',
|
|
1071
|
+
]);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
public function test_cannot_create_user_with_duplicate_email(): void
|
|
1075
|
+
{
|
|
1076
|
+
$admin = User::factory()->create(['role' => 'admin']);
|
|
1077
|
+
$existing = User::factory()->create();
|
|
1078
|
+
|
|
1079
|
+
Sanctum::actingAs($admin);
|
|
1080
|
+
|
|
1081
|
+
$response = $this->postJson('/api/users', [
|
|
1082
|
+
'name' => 'New User',
|
|
1083
|
+
'email' => $existing->email,
|
|
1084
|
+
'password' => 'SecurePass123!',
|
|
1085
|
+
'password_confirmation' => 'SecurePass123!',
|
|
1086
|
+
]);
|
|
1087
|
+
|
|
1088
|
+
$response->assertUnprocessable()
|
|
1089
|
+
->assertJsonValidationErrors(['email']);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
// tests/Feature/AuthTest.php
|
|
1095
|
+
namespace Tests\Feature;
|
|
1096
|
+
|
|
1097
|
+
use App\Models\User;
|
|
1098
|
+
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
1099
|
+
use Tests\TestCase;
|
|
1100
|
+
|
|
1101
|
+
class AuthTest extends TestCase
|
|
1102
|
+
{
|
|
1103
|
+
use RefreshDatabase;
|
|
1104
|
+
|
|
1105
|
+
public function test_user_can_register(): void
|
|
1106
|
+
{
|
|
1107
|
+
$response = $this->postJson('/api/auth/register', [
|
|
1108
|
+
'name' => 'Test User',
|
|
1109
|
+
'email' => 'test@example.com',
|
|
1110
|
+
'password' => 'SecurePass123!',
|
|
1111
|
+
'password_confirmation' => 'SecurePass123!',
|
|
1112
|
+
]);
|
|
1113
|
+
|
|
1114
|
+
$response->assertCreated()
|
|
1115
|
+
->assertJsonStructure([
|
|
1116
|
+
'user' => ['id', 'email', 'name'],
|
|
1117
|
+
'token',
|
|
1118
|
+
]);
|
|
1119
|
+
|
|
1120
|
+
$this->assertDatabaseHas('users', [
|
|
1121
|
+
'email' => 'test@example.com',
|
|
1122
|
+
]);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
public function test_user_can_login(): void
|
|
1126
|
+
{
|
|
1127
|
+
$user = User::factory()->create([
|
|
1128
|
+
'password' => bcrypt('password123'),
|
|
1129
|
+
]);
|
|
1130
|
+
|
|
1131
|
+
$response = $this->postJson('/api/auth/login', [
|
|
1132
|
+
'email' => $user->email,
|
|
1133
|
+
'password' => 'password123',
|
|
1134
|
+
]);
|
|
1135
|
+
|
|
1136
|
+
$response->assertOk()
|
|
1137
|
+
->assertJsonStructure([
|
|
1138
|
+
'user' => ['id', 'email'],
|
|
1139
|
+
'token',
|
|
1140
|
+
]);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
public function test_login_fails_with_wrong_password(): void
|
|
1144
|
+
{
|
|
1145
|
+
$user = User::factory()->create();
|
|
1146
|
+
|
|
1147
|
+
$response = $this->postJson('/api/auth/login', [
|
|
1148
|
+
'email' => $user->email,
|
|
1149
|
+
'password' => 'wrong-password',
|
|
1150
|
+
]);
|
|
1151
|
+
|
|
1152
|
+
$response->assertUnauthorized();
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
## Use Cases
|
|
1158
|
+
|
|
1159
|
+
### Queue Jobs for Background Processing
|
|
1160
|
+
|
|
1161
|
+
```php
|
|
1162
|
+
<?php
|
|
1163
|
+
// app/Jobs/SendWelcomeEmail.php
|
|
1164
|
+
namespace App\Jobs;
|
|
1165
|
+
|
|
1166
|
+
use App\Mail\WelcomeEmail;
|
|
1167
|
+
use App\Models\User;
|
|
1168
|
+
use Illuminate\Bus\Queueable;
|
|
1169
|
+
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
1170
|
+
use Illuminate\Foundation\Bus\Dispatchable;
|
|
1171
|
+
use Illuminate\Queue\InteractsWithQueue;
|
|
1172
|
+
use Illuminate\Queue\SerializesModels;
|
|
1173
|
+
use Illuminate\Support\Facades\Mail;
|
|
1174
|
+
|
|
1175
|
+
class SendWelcomeEmail implements ShouldQueue
|
|
1176
|
+
{
|
|
1177
|
+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
1178
|
+
|
|
1179
|
+
public int $tries = 3;
|
|
1180
|
+
public int $backoff = 60;
|
|
1181
|
+
|
|
1182
|
+
public function __construct(
|
|
1183
|
+
public readonly User $user
|
|
1184
|
+
) {}
|
|
1185
|
+
|
|
1186
|
+
public function handle(): void
|
|
1187
|
+
{
|
|
1188
|
+
Mail::to($this->user->email)
|
|
1189
|
+
->send(new WelcomeEmail($this->user));
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
public function failed(\Throwable $exception): void
|
|
1193
|
+
{
|
|
1194
|
+
// Log or notify about the failure
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Usage
|
|
1199
|
+
SendWelcomeEmail::dispatch($user);
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
### Event-Driven Architecture
|
|
1203
|
+
|
|
1204
|
+
```php
|
|
1205
|
+
<?php
|
|
1206
|
+
// app/Events/UserCreated.php
|
|
1207
|
+
namespace App\Events;
|
|
1208
|
+
|
|
1209
|
+
use App\Models\User;
|
|
1210
|
+
use Illuminate\Foundation\Events\Dispatchable;
|
|
1211
|
+
use Illuminate\Queue\SerializesModels;
|
|
1212
|
+
|
|
1213
|
+
class UserCreated
|
|
1214
|
+
{
|
|
1215
|
+
use Dispatchable, SerializesModels;
|
|
1216
|
+
|
|
1217
|
+
public function __construct(
|
|
1218
|
+
public readonly User $user
|
|
1219
|
+
) {}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// app/Listeners/SendWelcomeNotification.php
|
|
1223
|
+
namespace App\Listeners;
|
|
1224
|
+
|
|
1225
|
+
use App\Events\UserCreated;
|
|
1226
|
+
use App\Jobs\SendWelcomeEmail;
|
|
1227
|
+
|
|
1228
|
+
class SendWelcomeNotification
|
|
1229
|
+
{
|
|
1230
|
+
public function handle(UserCreated $event): void
|
|
1231
|
+
{
|
|
1232
|
+
SendWelcomeEmail::dispatch($event->user);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// app/Providers/EventServiceProvider.php
|
|
1237
|
+
protected $listen = [
|
|
1238
|
+
UserCreated::class => [
|
|
1239
|
+
SendWelcomeNotification::class,
|
|
1240
|
+
],
|
|
1241
|
+
];
|
|
59
1242
|
```
|
|
60
1243
|
|
|
61
1244
|
## Best Practices
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
- Use
|
|
1245
|
+
|
|
1246
|
+
### Do's
|
|
1247
|
+
|
|
1248
|
+
- Use UUID primary keys for public APIs
|
|
1249
|
+
- Use Form Requests for validation
|
|
1250
|
+
- Use API Resources for response formatting
|
|
1251
|
+
- Use service classes for business logic
|
|
1252
|
+
- Use policies for authorization
|
|
1253
|
+
- Use eager loading to prevent N+1
|
|
1254
|
+
- Use database transactions for writes
|
|
1255
|
+
- Write feature and unit tests
|
|
1256
|
+
- Use queues for heavy operations
|
|
1257
|
+
- Use soft deletes for important data
|
|
1258
|
+
|
|
1259
|
+
### Don'ts
|
|
1260
|
+
|
|
1261
|
+
- Don't put business logic in controllers
|
|
1262
|
+
- Don't use raw queries without bindings
|
|
1263
|
+
- Don't ignore validation
|
|
1264
|
+
- Don't skip authorization checks
|
|
1265
|
+
- Don't expose internal IDs
|
|
1266
|
+
- Don't use mutable defaults
|
|
1267
|
+
- Don't ignore exceptions
|
|
1268
|
+
- Don't skip testing
|
|
1269
|
+
- Don't use sync for heavy tasks
|
|
1270
|
+
- Don't forget rate limiting
|
|
1271
|
+
|
|
1272
|
+
## References
|
|
1273
|
+
|
|
1274
|
+
- [Laravel Documentation](https://laravel.com/docs)
|
|
1275
|
+
- [Laravel Best Practices](https://github.com/alexeymezenin/laravel-best-practices)
|
|
1276
|
+
- [Laravel API Tutorial](https://laravel.com/docs/eloquent-resources)
|
|
1277
|
+
- [Laravel Testing](https://laravel.com/docs/testing)
|
|
1278
|
+
- [Spatie Guidelines](https://spatie.be/guidelines/laravel-php)
|