From 035dfed40206a56693612fa1c4339d8d564009d4 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Sat, 22 Aug 2026 21:39:42 +0330
Subject: [PATCH 01/22] chore: update composer package name and description
---
composer.json | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/composer.json b/composer.json
index 36da9d4..c62f73d 100644
--- a/composer.json
+++ b/composer.json
@@ -1,11 +1,13 @@
{
"$schema": "https://getcomposer.org/schema.json",
- "name": "laravel/laravel",
+ "name": "mahdiimax/taskify",
"type": "project",
- "description": "The skeleton application for the Laravel framework.",
+ "description": "A clean, RESTful task management API built with Laravel",
"keywords": [
"laravel",
- "framework"
+ "api",
+ "task-management",
+ "sanctum"
],
"license": "MIT",
"require": {
From 852c2ef6c22ded96edb622a4c292ad6b5e3d44ff Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Sat, 22 Aug 2026 21:42:13 +0330
Subject: [PATCH 02/22] refactor: cast model attributes to enums and extract
task filter scope
---
app/Models/Project.php | 13 +++++++++++++
app/Models/Task.php | 26 ++++++++++++++++++++++++++
2 files changed, 39 insertions(+)
diff --git a/app/Models/Project.php b/app/Models/Project.php
index cbaffba..9cd57da 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Enums\ProjectColor;
use App\Http\Resources\Api\V1\ProjectResource;
use Illuminate\Database\Eloquent\Attributes\UseResource;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -22,6 +23,18 @@ class Project extends Model
'user_id',
];
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'color' => ProjectColor::class,
+ ];
+ }
+
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
diff --git a/app/Models/Task.php b/app/Models/Task.php
index 43c9596..3c52b00 100644
--- a/app/Models/Task.php
+++ b/app/Models/Task.php
@@ -2,8 +2,11 @@
namespace App\Models;
+use App\Enums\TaskPriority;
+use App\Enums\TaskStatus;
use App\Http\Resources\Api\V1\TaskResource;
use Illuminate\Database\Eloquent\Attributes\UseResource;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -24,6 +27,15 @@ class Task extends Model
'due_date',
];
+ protected function casts(): array
+ {
+ return [
+ 'status' => TaskStatus::class,
+ 'priority' => TaskPriority::class,
+ 'due_date' => 'datetime',
+ ];
+ }
+
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
@@ -33,4 +45,18 @@ public function project(): BelongsTo
{
return $this->belongsTo(Project::class);
}
+
+ public function scopeFilter(Builder $query, array $filters): Builder
+ {
+ return $query
+ ->when($filters['search'] ?? null, function (Builder $query, string $search) {
+ $query->where(function (Builder $query) use ($search) {
+ $query->whereLike('title', "%{$search}%")
+ ->orWhereLike('description', "%{$search}%");
+ });
+ })
+ ->when($filters['status'] ?? null, fn (Builder $query, string $status) => $query->where('status', $status))
+ ->when($filters['priority'] ?? null, fn (Builder $query, string $priority) => $query->where('priority', $priority))
+ ->when($filters['project_id'] ?? null, fn (Builder $query, int|string $projectId) => $query->where('project_id', $projectId));
+ }
}
From f5eead3a9a06f5765140899f9aacee2ba69fa4a6 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Sat, 22 Aug 2026 21:44:24 +0330
Subject: [PATCH 03/22] fix: return empty 204 responses and support per_page
pagination
---
.../Controllers/Api/V1/AuthController.php | 2 +-
.../Controllers/Api/V1/ProjectController.php | 11 ++----
.../Controllers/Api/V1/TaskController.php | 39 +++++++------------
.../Requests/Api/V1/Task/IndexTaskRequest.php | 1 +
4 files changed, 20 insertions(+), 33 deletions(-)
diff --git a/app/Http/Controllers/Api/V1/AuthController.php b/app/Http/Controllers/Api/V1/AuthController.php
index d6fa060..a214919 100644
--- a/app/Http/Controllers/Api/V1/AuthController.php
+++ b/app/Http/Controllers/Api/V1/AuthController.php
@@ -27,7 +27,7 @@ public function register(RegisterRequest $request): JsonResponse
$user = User::create([
'name' => $request->name,
'email' => $request->email,
- 'password' => Hash::make($request->password),
+ 'password' => $request->password,
]);
return response()->json([
diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php
index c823677..63bfcba 100644
--- a/app/Http/Controllers/Api/V1/ProjectController.php
+++ b/app/Http/Controllers/Api/V1/ProjectController.php
@@ -13,6 +13,7 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Http\Response as HttpResponse;
#[Group('Projects')]
class ProjectController extends Controller
@@ -87,16 +88,12 @@ public function update(UpdateProjectRequest $request, Project $project): JsonRes
/**
* Remove the specified resource from storage.
*/
- #[Response(status: 204, description: 'Project deleted successfully', examples: [[
- 'message' => 'Project deleted successfully',
- ]])]
- public function destroy(Project $project): JsonResponse
+ #[Response(status: 204, description: 'Project deleted successfully')]
+ public function destroy(Project $project): HttpResponse
{
$project->delete();
- return response()->json([
- 'message' => 'Project deleted successfully',
- ], 204);
+ return response()->noContent();
}
/**
diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php
index 615e0e2..023fcc0 100644
--- a/app/Http/Controllers/Api/V1/TaskController.php
+++ b/app/Http/Controllers/Api/V1/TaskController.php
@@ -12,8 +12,8 @@
use Dedoc\Scramble\Attributes\Response;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
-use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Http\Response as HttpResponse;
#[Group('Tasks')]
class TaskController extends Controller
@@ -31,21 +31,9 @@ class TaskController extends Controller
public function index(IndexTaskRequest $request): AnonymousResourceCollection
{
$tasks = $request->user()->tasks()
- ->when($request->filled('search'), function ($query) use ($request) {
- $search = '%'.strtolower($request->input('search')).'%';
- $query->where(function ($query) use ($search) {
- $query->whereRaw('LOWER(title) LIKE ?', [$search])
- ->orWhereRaw('LOWER(description) LIKE ?', [$search]);
- });
- })
- ->when($request->filled('status'), function ($query) use ($request) {
- $query->where('status', $request->input('status'));
- })
- ->when($request->filled('priority'), function ($query) use ($request) {
- $query->where('priority', $request->input('priority'));
- })->when($request->filled('project_id'), function ($query) use ($request) {
- $query->where('project_id', $request->input('project_id'));
- })->latest()->paginate(10);
+ ->filter($request->validated())
+ ->latest()
+ ->paginate((int) $request->validated('per_page', 10));
return TaskResource::collection($tasks);
}
@@ -76,6 +64,7 @@ public function store(StoreTaskRequest $request): JsonResponse
public function show(Task $task): TaskResource
{
$this->authorize('view', $task);
+ $task->loadMissing('user');
return new TaskResource($task);
}
@@ -91,6 +80,7 @@ public function update(UpdateTaskRequest $request, Task $task): JsonResponse
{
$this->authorize('update', $task);
$task->update($request->validated());
+ $task->loadMissing('user');
return response()->json([
'message' => 'Task updated successfully',
@@ -101,17 +91,13 @@ public function update(UpdateTaskRequest $request, Task $task): JsonResponse
/**
* Remove the specified task from storage.
*/
- #[Response(status: 204, description: 'Task deleted successfully', examples: [[
- 'message' => 'Task deleted successfully',
- ]])]
- public function destroy(Task $task): JsonResponse
+ #[Response(status: 204, description: 'Task deleted successfully')]
+ public function destroy(Task $task): HttpResponse
{
$this->authorize('delete', $task);
$task->delete();
- return response()->json([
- 'message' => 'Task deleted successfully',
- ], 204);
+ return response()->noContent();
}
/**
@@ -122,9 +108,12 @@ public function destroy(Task $task): JsonResponse
'links' => ['first' => null, 'last' => null, 'prev' => null, 'next' => null],
'meta' => ['current_page' => 1, 'from' => 1, 'last_page' => 1, 'path' => 'http://localhost:8000/api/v1/tasks/trashed', 'per_page' => 10, 'to' => 1, 'total' => 1],
]])]
- public function trashed(Request $request): AnonymousResourceCollection
+ public function trashed(IndexTaskRequest $request): AnonymousResourceCollection
{
- $trashedTasks = $request->user()->tasks()->onlyTrashed()->latest()->paginate(10);
+ $trashedTasks = $request->user()->tasks()
+ ->onlyTrashed()
+ ->latest()
+ ->paginate((int) $request->validated('per_page', 10));
return TaskResource::collection($trashedTasks);
}
diff --git a/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php b/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php
index b17d166..1aa8190 100644
--- a/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php
+++ b/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php
@@ -30,6 +30,7 @@ public function rules(): array
'status' => ['nullable', Rule::enum(TaskStatus::class)],
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
'project_id' => ['nullable', Rule::exists('projects', 'id')->where('user_id', $this->user()->id)],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}
From fd6e1f783d0c644308ed1641a27baca9f45ee88b Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Sat, 22 Aug 2026 21:46:18 +0330
Subject: [PATCH 04/22] test: cover per_page limits, no-content deletes, and
enum casts
---
tests/Feature/Api/V1/ProjectTest.php | 2 +-
tests/Feature/Api/V1/TaskTest.php | 31 ++++++++++++++++++++++++++--
2 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/tests/Feature/Api/V1/ProjectTest.php b/tests/Feature/Api/V1/ProjectTest.php
index 3549de7..612a4ec 100644
--- a/tests/Feature/Api/V1/ProjectTest.php
+++ b/tests/Feature/Api/V1/ProjectTest.php
@@ -73,7 +73,7 @@
$project = Project::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)
->deleteJson(route('api.v1.projects.destroy', $project))
- ->assertStatus(204);
+ ->assertNoContent();
$this->assertSoftDeleted('projects', ['id' => $project->id]);
$this->actingAs($user)
->postJson(route('api.v1.projects.restore', $project))
diff --git a/tests/Feature/Api/V1/TaskTest.php b/tests/Feature/Api/V1/TaskTest.php
index d2e3ef8..748ec63 100644
--- a/tests/Feature/Api/V1/TaskTest.php
+++ b/tests/Feature/Api/V1/TaskTest.php
@@ -99,7 +99,7 @@
$task = Task::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->deleteJson(route('api.v1.tasks.destroy', ['task' => $task->id]));
- $response->assertStatus(204);
+ $response->assertNoContent();
$this->assertSoftDeleted('tasks', ['id' => $task->id]);
$restoreResponse = $this->actingAs($user)->postJson(route('api.v1.tasks.restore', ['task' => $task->id]));
@@ -206,7 +206,7 @@
$this->withHeader('Authorization', "Bearer {$token}")
->deleteJson(route('api.v1.tasks.destroy', $task))
- ->assertStatus(204);
+ ->assertNoContent();
$this->assertSoftDeleted('tasks', ['id' => $task->id]);
});
@@ -231,3 +231,30 @@
->getJson(route('api.v1.tasks.index'))
->assertStatus(401);
});
+
+test('tasks list supports custom per_page', function () {
+ $user = User::factory()->create();
+ Task::factory(15)->create(['user_id' => $user->id]);
+
+ $this->actingAs($user)
+ ->getJson(route('api.v1.tasks.index', ['per_page' => 5]))
+ ->assertOk()
+ ->assertJsonCount(5, 'data')
+ ->assertJsonPath('meta.per_page', 5);
+});
+
+test('per_page above 100 is rejected', function () {
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->getJson(route('api.v1.tasks.index', ['per_page' => 101]))
+ ->assertStatus(422);
+});
+
+test('task model casts status and priority to enums', function () {
+ $user = User::factory()->create();
+ $task = Task::factory()->create(['user_id' => $user->id]);
+
+ expect($task->status)->toBeInstanceOf(TaskStatus::class)
+ ->and($task->priority)->toBeInstanceOf(TaskPriority::class);
+});
From 38743af41ff8855c8d863408aafb3387c21ad4d4 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Sat, 22 Aug 2026 21:46:43 +0330
Subject: [PATCH 05/22] docs: document per_page parameter and 204 status code
---
README.md | 2 +
api.json | 263 ++++++++++++++++++++++--------------------------------
2 files changed, 110 insertions(+), 155 deletions(-)
diff --git a/README.md b/README.md
index 50ce9a4..f821ac1 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,7 @@ Tokens are issued by `login`, live **24 hours**, and are revoked by `logout`.
| `priority` | `?priority=high` | exact match |
| `search` | `?search=buy milk` | case-insensitive partial match on title/description |
| `project_id` | `?project_id=3` | exact match (your projects only) |
+| `per_page` | `?per_page=25` | page size 1–100, default 10 |
Invalid `status`/`priority` values → `422`.
@@ -236,6 +237,7 @@ Invalid `status`/`priority` values → `422`.
| --- | --- |
| `200` | Success |
| `201` | Created |
+| `204` | Deleted (empty response body) |
| `400` | Already authenticated (on login/register) |
| `401` | Unauthenticated / expired or revoked token |
| `403` | Forbidden (another user's resource) |
diff --git a/api.json b/api.json
index 641db27..b123d5c 100644
--- a/api.json
+++ b/api.json
@@ -729,21 +729,7 @@
"content": {
"application/json": {
"schema": {
- "type": "object",
- "examples": [
- {
- "message": "Project deleted successfully"
- }
- ],
- "properties": {
- "message": {
- "type": "string",
- "const": "Project deleted successfully"
- }
- },
- "required": [
- "message"
- ]
+ "type": "string"
}
}
}
@@ -767,6 +753,69 @@
"tags": [
"Tasks"
],
+ "parameters": [
+ {
+ "name": "search",
+ "in": "query",
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "maxLength": 255
+ }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "schema": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/TaskStatus"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ {
+ "name": "priority",
+ "in": "query",
+ "schema": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/TaskPriority"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ {
+ "name": "project_id",
+ "in": "query",
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "name": "per_page",
+ "in": "query",
+ "schema": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ ],
"responses": {
"200": {
"description": "Paginated list of the user's trashed tasks",
@@ -827,6 +876,9 @@
},
"401": {
"$ref": "#/components/responses/AuthenticationException"
+ },
+ "422": {
+ "$ref": "#/components/responses/ValidationException"
}
}
}
@@ -955,6 +1007,18 @@
"null"
]
}
+ },
+ {
+ "name": "per_page",
+ "in": "query",
+ "schema": {
+ "type": [
+ "integer",
+ "null"
+ ],
+ "minimum": 1,
+ "maximum": 100
+ }
}
],
"responses": {
@@ -1000,128 +1064,10 @@
"items": {
"$ref": "#/components/schemas/TaskResource"
}
- },
- "links": {
- "type": "object",
- "properties": {
- "first": {
- "type": [
- "string",
- "null"
- ]
- },
- "last": {
- "type": [
- "string",
- "null"
- ]
- },
- "prev": {
- "type": [
- "string",
- "null"
- ]
- },
- "next": {
- "type": [
- "string",
- "null"
- ]
- }
- },
- "required": [
- "first",
- "last",
- "prev",
- "next"
- ]
- },
- "meta": {
- "type": "object",
- "properties": {
- "current_page": {
- "type": "integer",
- "minimum": 1
- },
- "from": {
- "type": [
- "integer",
- "null"
- ],
- "minimum": 1
- },
- "last_page": {
- "type": "integer",
- "minimum": 1
- },
- "links": {
- "type": "array",
- "description": "Generated paginator links.",
- "items": {
- "type": "object",
- "properties": {
- "url": {
- "type": [
- "string",
- "null"
- ]
- },
- "label": {
- "type": "string"
- },
- "active": {
- "type": "boolean"
- }
- },
- "required": [
- "url",
- "label",
- "active"
- ]
- }
- },
- "path": {
- "type": [
- "string",
- "null"
- ],
- "description": "Base path for paginator generated URLs."
- },
- "per_page": {
- "type": "integer",
- "description": "Number of items shown per page.",
- "minimum": 0
- },
- "to": {
- "type": [
- "integer",
- "null"
- ],
- "description": "Number of the last item in the slice.",
- "minimum": 1
- },
- "total": {
- "type": "integer",
- "description": "Total number of items being paginated.",
- "minimum": 0
- }
- },
- "required": [
- "current_page",
- "from",
- "last_page",
- "links",
- "path",
- "per_page",
- "to",
- "total"
- ]
}
},
"required": [
- "data",
- "links",
- "meta"
+ "data"
]
}
}
@@ -1244,7 +1190,17 @@
],
"properties": {
"data": {
- "$ref": "#/components/schemas/TaskResource"
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/TaskResource"
+ },
+ {
+ "type": "object",
+ "required": [
+ "user"
+ ]
+ }
+ ]
}
},
"required": [
@@ -1324,7 +1280,17 @@
"const": "Task updated successfully"
},
"data": {
- "$ref": "#/components/schemas/TaskResource"
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/TaskResource"
+ },
+ {
+ "type": "object",
+ "required": [
+ "user"
+ ]
+ }
+ ]
}
},
"required": [
@@ -1372,21 +1338,7 @@
"content": {
"application/json": {
"schema": {
- "type": "object",
- "examples": [
- {
- "message": "Task deleted successfully"
- }
- ],
- "properties": {
- "message": {
- "type": "string",
- "const": "Task deleted successfully"
- }
- },
- "required": [
- "message"
- ]
+ "type": "string"
}
}
}
@@ -1470,7 +1422,7 @@
]
},
"color": {
- "type": "string"
+ "$ref": "#/components/schemas/ProjectColor"
},
"user": {
"$ref": "#/components/schemas/UserResource"
@@ -1641,16 +1593,17 @@
]
},
"status": {
- "type": "string"
+ "$ref": "#/components/schemas/TaskStatus"
},
"priority": {
- "type": "string"
+ "$ref": "#/components/schemas/TaskPriority"
},
"due_date": {
"type": [
"string",
"null"
- ]
+ ],
+ "format": "date-time"
},
"project_id": {
"type": [
From f7f75efa6858396f6ded585003efb21f96739aef Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Mon, 24 Aug 2026 16:32:05 +0330
Subject: [PATCH 06/22] feat: add sortable fields whitelist and sort scope to
tasks
---
app/Models/Task.php | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/app/Models/Task.php b/app/Models/Task.php
index 3c52b00..287c41d 100644
--- a/app/Models/Task.php
+++ b/app/Models/Task.php
@@ -17,6 +17,13 @@ class Task extends Model
{
use HasFactory, SoftDeletes;
+ /**
+ * Fields that may be used to sort task listings.
+ *
+ * @var list
+ */
+ public const SORTABLE_FIELDS = ['title', 'status', 'priority', 'due_date', 'created_at'];
+
protected $fillable = [
'title',
'description',
@@ -59,4 +66,25 @@ public function scopeFilter(Builder $query, array $filters): Builder
->when($filters['priority'] ?? null, fn (Builder $query, string $priority) => $query->where('priority', $priority))
->when($filters['project_id'] ?? null, fn (Builder $query, int|string $projectId) => $query->where('project_id', $projectId));
}
+
+ /**
+ * Apply sorting from a comma-separated sort string ("-" prefix = descending).
+ * Falls back to newest-first when no sort is given.
+ */
+ public function scopeSort(Builder $query, ?string $sort): Builder
+ {
+ $fields = array_filter(array_map('trim', explode(',', (string) $sort)));
+ if ($fields === []) {
+ return $query->latest();
+ }
+ foreach ($fields as $field) {
+ $direction = str_starts_with($field, '-') ? 'desc' : 'asc';
+ $column = ltrim($field, '-');
+ if (in_array($column, self::SORTABLE_FIELDS, true)) {
+ $query->orderBy($column, $direction);
+ }
+ }
+
+ return $query;
+ }
}
From cfa168a751e0dcddddc1bcd191052f556e5eb8c7 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Mon, 24 Aug 2026 16:34:16 +0330
Subject: [PATCH 07/22] feat: validate sort parameter against allowed fields
---
app/Http/Requests/Api/V1/Task/IndexTaskRequest.php | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php b/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php
index 1aa8190..57386da 100644
--- a/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php
+++ b/app/Http/Requests/Api/V1/Task/IndexTaskRequest.php
@@ -4,6 +4,8 @@
use App\Enums\TaskPriority;
use App\Enums\TaskStatus;
+use App\Models\Task;
+use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -31,6 +33,17 @@ public function rules(): array
'priority' => ['nullable', Rule::enum(TaskPriority::class)],
'project_id' => ['nullable', Rule::exists('projects', 'id')->where('user_id', $this->user()->id)],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ 'sort' => [
+ 'nullable',
+ 'string',
+ function (string $attribute, mixed $value, Closure $fail) {
+ foreach (explode(',', (string) $value) as $field) {
+ if (! in_array(ltrim(trim($field), '-'), Task::SORTABLE_FIELDS, true)) {
+ $fail("The {$field} sort field is not supported.");
+ }
+ }
+ },
+ ],
];
}
}
From 32228ae82732c755c2cfdcc6dc6f54185e103133 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Mon, 24 Aug 2026 16:34:56 +0330
Subject: [PATCH 08/22] feat: add task statistics endpoint and wire sorting
into task listing
---
.../Controllers/Api/V1/TaskController.php | 55 ++++++++++++++++++-
1 file changed, 54 insertions(+), 1 deletion(-)
diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php
index 023fcc0..8cc6dac 100644
--- a/app/Http/Controllers/Api/V1/TaskController.php
+++ b/app/Http/Controllers/Api/V1/TaskController.php
@@ -2,6 +2,8 @@
namespace App\Http\Controllers\Api\V1;
+use App\Enums\TaskPriority;
+use App\Enums\TaskStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\Task\IndexTaskRequest;
use App\Http\Requests\Api\V1\Task\StoreTaskRequest;
@@ -12,6 +14,7 @@
use Dedoc\Scramble\Attributes\Response;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response as HttpResponse;
@@ -32,7 +35,7 @@ public function index(IndexTaskRequest $request): AnonymousResourceCollection
{
$tasks = $request->user()->tasks()
->filter($request->validated())
- ->latest()
+ ->sort($request->input('sort'))
->paginate((int) $request->validated('per_page', 10));
return TaskResource::collection($tasks);
@@ -131,4 +134,54 @@ public function restore(Task $task): TaskResource
return new TaskResource($task);
}
+
+ /**
+ * Aggregate statistics for the authenticated user's tasks.
+ */
+ #[Response(status: 200, description: 'Task statistics', examples: [[
+ 'data' => [
+ 'total' => 42,
+ 'by_status' => ['pending' => 10, 'in_progress' => 12, 'done' => 20],
+ 'by_priority' => ['low' => 8, 'medium' => 14, 'high' => 20],
+ 'overdue' => 3,
+ 'due_today' => 2,
+ 'completed_this_week' => 5,
+ ],
+ ]])]
+ public function stats(Request $request): JsonResponse
+ {
+ $user = $request->user();
+ $byStatus = $user->tasks()
+ ->select('status')
+ ->selectRaw('COUNT(*) AS aggregate')
+ ->groupBy('status')
+ ->pluck('aggregate', 'status');
+ $byPriority = $user->tasks()
+ ->select('priority')
+ ->selectRaw('COUNT(*) AS aggregate')
+ ->groupBy('priority')
+ ->pluck('aggregate', 'priority');
+
+ return response()->json([
+ 'data' => [
+ 'total' => $user->tasks()->count(),
+ 'by_status' => collect(TaskStatus::cases())
+ ->mapWithKeys(fn (TaskStatus $status) => [$status->value => (int) ($byStatus[$status->value] ?? 0)]),
+ 'by_priority' => collect(TaskPriority::cases())
+ ->mapWithKeys(fn (TaskPriority $priority) => [$priority->value => (int) ($byPriority[$priority->value] ?? 0)]),
+ 'overdue' => $user->tasks()
+ ->whereNotNull('due_date')
+ ->where('due_date', '<', now())
+ ->where('status', '!=', TaskStatus::DONE->value)
+ ->count(),
+ 'due_today' => $user->tasks()
+ ->whereBetween('due_date', [now()->startOfDay(), now()->endOfDay()])
+ ->count(),
+ 'completed_this_week' => $user->tasks()
+ ->where('status', TaskStatus::DONE->value)
+ ->where('updated_at', '>=', now()->startOfWeek())
+ ->count(),
+ ],
+ ]);
+ }
}
From 9bf2f545b2b151efa115affea39dc7d639011e6e Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Mon, 24 Aug 2026 16:35:27 +0330
Subject: [PATCH 09/22] feat: register stats route
---
routes/api.php | 1 +
1 file changed, 1 insertion(+)
diff --git a/routes/api.php b/routes/api.php
index 49b281b..8f2ff50 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -16,6 +16,7 @@
Route::middleware('auth:sanctum')->group(function () {
Route::prefix('tasks')->controller(TaskController::class)->name('tasks.')->group(function () {
Route::get('trashed', 'trashed')->name('trashed');
+ Route::get('stats', 'stats')->name('stats');
Route::post('{task}/restore', 'restore')->withTrashed()->name('restore');
});
Route::apiResource('tasks', TaskController::class);
From 56dc485c5c245d92f5235904180ba8f56a9518f9 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Mon, 24 Aug 2026 16:35:51 +0330
Subject: [PATCH 10/22] test: cover task statistics and sorting behavior
---
tests/Feature/Api/V1/TaskTest.php | 64 +++++++++++++++++++++++++++++++
1 file changed, 64 insertions(+)
diff --git a/tests/Feature/Api/V1/TaskTest.php b/tests/Feature/Api/V1/TaskTest.php
index 748ec63..e83c4b0 100644
--- a/tests/Feature/Api/V1/TaskTest.php
+++ b/tests/Feature/Api/V1/TaskTest.php
@@ -258,3 +258,67 @@
expect($task->status)->toBeInstanceOf(TaskStatus::class)
->and($task->priority)->toBeInstanceOf(TaskPriority::class);
});
+
+test('user can retrieve their task statistics', function () {
+ $user = User::factory()->create();
+ $other = User::factory()->create();
+
+ Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::PENDING, 'priority' => TaskPriority::HIGH]);
+ Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::DONE, 'priority' => TaskPriority::LOW]);
+ Task::factory()->create(['user_id' => $other->id]);
+
+ $response = $this->actingAs($user)->getJson(route('api.v1.tasks.stats'))->assertOk();
+
+ expect($response->json('data.total'))->toBe(2)
+ ->and($response->json('data.by_status.pending'))->toBe(1)
+ ->and($response->json('data.by_status.in_progress'))->toBe(0)
+ ->and($response->json('data.by_status.done'))->toBe(1)
+ ->and($response->json('data.by_priority.high'))->toBe(1)
+ ->and($response->json('data.by_priority.low'))->toBe(1)
+ ->and($response->json('data.by_priority.medium'))->toBe(0);
+});
+
+test('statistics include overdue, due today, and completed this week', function () {
+ $user = User::factory()->create();
+
+ Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::PENDING, 'due_date' => now()->subDay()]);
+ Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::PENDING, 'due_date' => now()->addHours(2)]);
+ Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::PENDING, 'due_date' => now()->addDays(5)]);
+ Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::DONE, 'due_date' => now()->subDay()]);
+ Task::factory()->create(['user_id' => $user->id, 'status' => TaskStatus::DONE]);
+
+ $response = $this->actingAs($user)->getJson(route('api.v1.tasks.stats'))->assertOk();
+
+ expect($response->json('data.overdue'))->toBe(1)
+ ->and($response->json('data.due_today'))->toBe(1)
+ ->and($response->json('data.completed_this_week'))->toBe(2);
+});
+
+test('unauthenticated user cannot access statistics', function () {
+ $this->getJson(route('api.v1.tasks.stats'))->assertStatus(401);
+});
+
+test('user can sort tasks ascending and descending', function () {
+ $user = User::factory()->create();
+ Task::factory()->create(['user_id' => $user->id, 'title' => 'Alpha']);
+ Task::factory()->create(['user_id' => $user->id, 'title' => 'Zulu']);
+ Task::factory()->create(['user_id' => $user->id, 'title' => 'Mike']);
+
+ $this->actingAs($user)
+ ->getJson(route('api.v1.tasks.index', ['sort' => '-title']))
+ ->assertOk()
+ ->assertJsonPath('data.*.title', ['Zulu', 'Mike', 'Alpha']);
+
+ $this->actingAs($user)
+ ->getJson(route('api.v1.tasks.index', ['sort' => 'title']))
+ ->assertOk()
+ ->assertJsonPath('data.*.title', ['Alpha', 'Mike', 'Zulu']);
+});
+
+test('unsupported sort field is rejected', function () {
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->getJson(route('api.v1.tasks.index', ['sort' => 'description']))
+ ->assertStatus(422);
+});
From 60057b08e73755a1206dbd6a2f234d59c8815386 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Mon, 24 Aug 2026 16:36:58 +0330
Subject: [PATCH 11/22] docs: update OpenAPI spec with statistics endpoint and
sorting
---
api.json | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 110 insertions(+)
diff --git a/api.json b/api.json
index b123d5c..7de0fe5 100644
--- a/api.json
+++ b/api.json
@@ -814,6 +814,16 @@
"minimum": 1,
"maximum": 100
}
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
}
],
"responses": {
@@ -883,6 +893,96 @@
}
}
},
+ "/v1/tasks/stats": {
+ "get": {
+ "operationId": "v1.tasks.stats",
+ "summary": "Aggregate statistics for the authenticated user's tasks",
+ "tags": [
+ "Tasks"
+ ],
+ "responses": {
+ "200": {
+ "description": "Task statistics",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "examples": [
+ {
+ "data": {
+ "total": 42,
+ "by_status": {
+ "pending": 10,
+ "in_progress": 12,
+ "done": 20
+ },
+ "by_priority": {
+ "low": 8,
+ "medium": 14,
+ "high": 20
+ },
+ "overdue": 3,
+ "due_today": 2,
+ "completed_this_week": 5
+ }
+ }
+ ],
+ "properties": {
+ "data": {
+ "type": "object",
+ "properties": {
+ "total": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "by_status": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "by_priority": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "overdue": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "due_today": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "completed_this_week": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ "required": [
+ "total",
+ "by_status",
+ "by_priority",
+ "overdue",
+ "due_today",
+ "completed_this_week"
+ ]
+ }
+ },
+ "required": [
+ "data"
+ ]
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/AuthenticationException"
+ }
+ }
+ }
+ },
"/v1/tasks/{task}/restore": {
"post": {
"operationId": "v1.tasks.restore",
@@ -1019,6 +1119,16 @@
"minimum": 1,
"maximum": 100
}
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
}
],
"responses": {
From 14886b18765ebe8ab6f30f0d6b1b957a67586000 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Mon, 24 Aug 2026 16:37:29 +0330
Subject: [PATCH 12/22] docs: document statistics endpoint and sort parameter
---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index f821ac1..f86b30c 100644
--- a/README.md
+++ b/README.md
@@ -186,6 +186,7 @@ Tokens are issued by `login`, live **24 hours**, and are revoked by `logout`.
| PUT/PATCH | `/tasks/{task}` | Update a task |
| DELETE | `/tasks/{task}` | Soft delete a task |
| GET | `/tasks/trashed` | List soft-deleted tasks |
+| GET | `/tasks/stats` | Aggregate statistics for your tasks |
| POST | `/tasks/{task}/restore` | Restore a soft-deleted task |
#### Task Fields
@@ -208,6 +209,7 @@ Tokens are issued by `login`, live **24 hours**, and are revoked by `logout`.
| `search` | `?search=buy milk` | case-insensitive partial match on title/description |
| `project_id` | `?project_id=3` | exact match (your projects only) |
| `per_page` | `?per_page=25` | page size 1–100, default 10 |
+| `sort` | `?sort=-due_date,title` | whitelist: title, status, priority, due_date, created_at · `-` prefix = descending |
Invalid `status`/`priority` values → `422`.
From 071e76f78eedbe4ca9f7b82bd660292d2e755893 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Fri, 28 Aug 2026 17:33:11 +0330
Subject: [PATCH 13/22] chore: add larastan as dev dependency
---
AGENTS.md | 10 ++-
composer.json | 1 +
composer.lock | 197 +++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 206 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 07492a6..ae038be 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/framework (LARAVEL) - v13
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
+- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
@@ -97,7 +98,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
-- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
+- Follow existing application Enum naming conventions.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
@@ -107,6 +108,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
+=== tests rules ===
+
+# Test Enforcement
+
+- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
+- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
+
=== laravel/core rules ===
# Do Things the Laravel Way
diff --git a/composer.json b/composer.json
index c62f73d..91b2df8 100644
--- a/composer.json
+++ b/composer.json
@@ -19,6 +19,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
+ "larastan/larastan": "^3.10",
"laravel/boost": "^2.2",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
diff --git a/composer.lock b/composer.lock
index 84ccb20..e9d4828 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "729a78bb11b6350b3fcfcabb236d3bd5",
+ "content-hash": "4749eaab7635b7251521126062f586f2",
"packages": [
{
"name": "brick/math",
@@ -6700,6 +6700,47 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
+ {
+ "name": "iamcal/sql-parser",
+ "version": "v0.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/iamcal/SQLParser.git",
+ "reference": "610392f38de49a44dab08dc1659960a29874c4b8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
+ "reference": "610392f38de49a44dab08dc1659960a29874c4b8",
+ "shasum": ""
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^1.0",
+ "phpunit/phpunit": "^5|^6|^7|^8|^9"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "iamcal\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Cal Henderson",
+ "email": "cal@iamcal.com"
+ }
+ ],
+ "description": "MySQL schema parser",
+ "support": {
+ "issues": "https://github.com/iamcal/SQLParser/issues",
+ "source": "https://github.com/iamcal/SQLParser/tree/v0.7"
+ },
+ "time": "2026-01-28T22:20:33+00:00"
+ },
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
@@ -6760,6 +6801,96 @@
},
"time": "2025-03-19T14:43:43+00:00"
},
+ {
+ "name": "larastan/larastan",
+ "version": "v3.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/larastan/larastan.git",
+ "reference": "2970f83398154178a739609c244577267c7ee8eb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb",
+ "reference": "2970f83398154178a739609c244577267c7ee8eb",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "iamcal/sql-parser": "^0.7.0",
+ "illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
+ "illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
+ "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
+ "illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
+ "illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
+ "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
+ "illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
+ "php": "^8.2",
+ "phpstan/phpstan": "^2.2.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^14",
+ "laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
+ "mockery/mockery": "^1.6.12",
+ "nikic/php-parser": "^5.4",
+ "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
+ "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
+ "phpstan/phpstan-deprecation-rules": "^2.0.1",
+ "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8"
+ },
+ "suggest": {
+ "orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
+ "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
+ },
+ "type": "phpstan-extension",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Larastan\\Larastan\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Can Vural",
+ "email": "can9119@gmail.com"
+ }
+ ],
+ "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
+ "keywords": [
+ "PHPStan",
+ "code analyse",
+ "code analysis",
+ "larastan",
+ "laravel",
+ "package",
+ "php",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/larastan/larastan/issues",
+ "source": "https://github.com/larastan/larastan/tree/v3.10.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/canvural",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-28T08:00:58+00:00"
+ },
{
"name": "laravel/agent-detector",
"version": "v2.0.2",
@@ -8209,6 +8340,70 @@
},
"time": "2026-01-06T21:53:42+00:00"
},
+ {
+ "name": "phpstan/phpstan",
+ "version": "2.2.9",
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0",
+ "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan-shim": "*"
+ },
+ "bin": [
+ "phpstan",
+ "phpstan.phar"
+ ],
+ "type": "library",
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ondřej Mirtes"
+ },
+ {
+ "name": "Markus Staab"
+ },
+ {
+ "name": "Vincent Langlet"
+ }
+ ],
+ "description": "PHPStan - PHP Static Analysis Tool",
+ "keywords": [
+ "dev",
+ "static analysis"
+ ],
+ "support": {
+ "docs": "https://phpstan.org/user-guide/getting-started",
+ "forum": "https://github.com/phpstan/phpstan/discussions",
+ "issues": "https://github.com/phpstan/phpstan/issues",
+ "security": "https://github.com/phpstan/phpstan/security/policy",
+ "source": "https://github.com/phpstan/phpstan-src"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/ondrejmirtes",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/phpstan",
+ "type": "github"
+ }
+ ],
+ "time": "2026-08-22T07:38:16+00:00"
+ },
{
"name": "phpunit/php-code-coverage",
"version": "14.2.4",
From a9f3d300e6eedfe56b940c14416a1f9076dd9711 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Fri, 28 Aug 2026 17:41:54 +0330
Subject: [PATCH 14/22] chore: configure phpstan at level 5
---
.gitignore | 1 +
phpstan.neon | 8 ++++++++
2 files changed, 9 insertions(+)
create mode 100644 phpstan.neon
diff --git a/.gitignore b/.gitignore
index 7be55e2..465c609 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,4 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+/build/phpstan
\ No newline at end of file
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..5613b4f
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,8 @@
+includes:
+ - vendor/larastan/larastan/extension.neon
+
+parameters:
+ paths:
+ - app
+ level: 5
+ tmpDir: build/phpstan
\ No newline at end of file
From 0ba3506a9dd4729083ae055f4a6a1edbb084e97e Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Fri, 28 Aug 2026 18:59:59 +0330
Subject: [PATCH 15/22] Revert "chore: configure phpstan at level 5"
This reverts commit a9f3d300e6eedfe56b940c14416a1f9076dd9711.
---
.gitignore | 1 -
phpstan.neon | 8 --------
2 files changed, 9 deletions(-)
delete mode 100644 phpstan.neon
diff --git a/.gitignore b/.gitignore
index 465c609..7be55e2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,4 +25,3 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
-/build/phpstan
\ No newline at end of file
diff --git a/phpstan.neon b/phpstan.neon
deleted file mode 100644
index 5613b4f..0000000
--- a/phpstan.neon
+++ /dev/null
@@ -1,8 +0,0 @@
-includes:
- - vendor/larastan/larastan/extension.neon
-
-parameters:
- paths:
- - app
- level: 5
- tmpDir: build/phpstan
\ No newline at end of file
From 9896c24c18e9e8f3a462b4f478cd012fd7214e0a Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Fri, 28 Aug 2026 19:00:05 +0330
Subject: [PATCH 16/22] Revert "chore: add larastan as dev dependency"
This reverts commit 071e76f78eedbe4ca9f7b82bd660292d2e755893.
---
AGENTS.md | 10 +--
composer.json | 1 -
composer.lock | 197 +-------------------------------------------------
3 files changed, 2 insertions(+), 206 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index ae038be..07492a6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,7 +13,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/framework (LARAVEL) - v13
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
-- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
@@ -98,7 +97,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
-- Follow existing application Enum naming conventions.
+- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
@@ -108,13 +107,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
-=== tests rules ===
-
-# Test Enforcement
-
-- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
-- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
-
=== laravel/core rules ===
# Do Things the Laravel Way
diff --git a/composer.json b/composer.json
index 91b2df8..c62f73d 100644
--- a/composer.json
+++ b/composer.json
@@ -19,7 +19,6 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
- "larastan/larastan": "^3.10",
"laravel/boost": "^2.2",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
diff --git a/composer.lock b/composer.lock
index e9d4828..84ccb20 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "4749eaab7635b7251521126062f586f2",
+ "content-hash": "729a78bb11b6350b3fcfcabb236d3bd5",
"packages": [
{
"name": "brick/math",
@@ -6700,47 +6700,6 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
- {
- "name": "iamcal/sql-parser",
- "version": "v0.7",
- "source": {
- "type": "git",
- "url": "https://github.com/iamcal/SQLParser.git",
- "reference": "610392f38de49a44dab08dc1659960a29874c4b8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
- "reference": "610392f38de49a44dab08dc1659960a29874c4b8",
- "shasum": ""
- },
- "require-dev": {
- "php-coveralls/php-coveralls": "^1.0",
- "phpunit/phpunit": "^5|^6|^7|^8|^9"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "iamcal\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Cal Henderson",
- "email": "cal@iamcal.com"
- }
- ],
- "description": "MySQL schema parser",
- "support": {
- "issues": "https://github.com/iamcal/SQLParser/issues",
- "source": "https://github.com/iamcal/SQLParser/tree/v0.7"
- },
- "time": "2026-01-28T22:20:33+00:00"
- },
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
@@ -6801,96 +6760,6 @@
},
"time": "2025-03-19T14:43:43+00:00"
},
- {
- "name": "larastan/larastan",
- "version": "v3.10.0",
- "source": {
- "type": "git",
- "url": "https://github.com/larastan/larastan.git",
- "reference": "2970f83398154178a739609c244577267c7ee8eb"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb",
- "reference": "2970f83398154178a739609c244577267c7ee8eb",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "iamcal/sql-parser": "^0.7.0",
- "illuminate/console": "^11.44.2 || ^12.4.1 || ^13",
- "illuminate/container": "^11.44.2 || ^12.4.1 || ^13",
- "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13",
- "illuminate/database": "^11.44.2 || ^12.4.1 || ^13",
- "illuminate/http": "^11.44.2 || ^12.4.1 || ^13",
- "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
- "illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
- "php": "^8.2",
- "phpstan/phpstan": "^2.2.0"
- },
- "require-dev": {
- "doctrine/coding-standard": "^14",
- "laravel/framework": "^11.44.2 || ^12.7.2 || ^13",
- "mockery/mockery": "^1.6.12",
- "nikic/php-parser": "^5.4",
- "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11",
- "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11",
- "phpstan/phpstan-deprecation-rules": "^2.0.1",
- "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8"
- },
- "suggest": {
- "orchestra/testbench": "Using Larastan for analysing a package needs Testbench",
- "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically"
- },
- "type": "phpstan-extension",
- "extra": {
- "phpstan": {
- "includes": [
- "extension.neon"
- ]
- },
- "branch-alias": {
- "dev-master": "3.0-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Larastan\\Larastan\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Can Vural",
- "email": "can9119@gmail.com"
- }
- ],
- "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
- "keywords": [
- "PHPStan",
- "code analyse",
- "code analysis",
- "larastan",
- "laravel",
- "package",
- "php",
- "static analysis"
- ],
- "support": {
- "issues": "https://github.com/larastan/larastan/issues",
- "source": "https://github.com/larastan/larastan/tree/v3.10.0"
- },
- "funding": [
- {
- "url": "https://github.com/canvural",
- "type": "github"
- }
- ],
- "time": "2026-05-28T08:00:58+00:00"
- },
{
"name": "laravel/agent-detector",
"version": "v2.0.2",
@@ -8340,70 +8209,6 @@
},
"time": "2026-01-06T21:53:42+00:00"
},
- {
- "name": "phpstan/phpstan",
- "version": "2.2.9",
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/13d6b4f347bad222da436580c8304fa6f83e6bd0",
- "reference": "13d6b4f347bad222da436580c8304fa6f83e6bd0",
- "shasum": ""
- },
- "require": {
- "php": "^7.4|^8.0"
- },
- "conflict": {
- "phpstan/phpstan-shim": "*"
- },
- "bin": [
- "phpstan",
- "phpstan.phar"
- ],
- "type": "library",
- "autoload": {
- "files": [
- "bootstrap.php"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Ondřej Mirtes"
- },
- {
- "name": "Markus Staab"
- },
- {
- "name": "Vincent Langlet"
- }
- ],
- "description": "PHPStan - PHP Static Analysis Tool",
- "keywords": [
- "dev",
- "static analysis"
- ],
- "support": {
- "docs": "https://phpstan.org/user-guide/getting-started",
- "forum": "https://github.com/phpstan/phpstan/discussions",
- "issues": "https://github.com/phpstan/phpstan/issues",
- "security": "https://github.com/phpstan/phpstan/security/policy",
- "source": "https://github.com/phpstan/phpstan-src"
- },
- "funding": [
- {
- "url": "https://github.com/ondrejmirtes",
- "type": "github"
- },
- {
- "url": "https://github.com/phpstan",
- "type": "github"
- }
- ],
- "time": "2026-08-22T07:38:16+00:00"
- },
{
"name": "phpunit/php-code-coverage",
"version": "14.2.4",
From a5d218fe7b7d8382a26e4267e1e81da5321e068b Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Fri, 28 Aug 2026 22:39:48 +0330
Subject: [PATCH 17/22] ci: add GitHub Actions pipeline with Pest Postgres
matrix
---
.github/workflows/ci.yml | 73 ++++++++++++++++++++++++++++++++++++++++
README.md | 1 +
2 files changed, 74 insertions(+)
create mode 100644 .github/workflows/ci.yml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..dd7c6ce
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,73 @@
+name: CI
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+
+jobs:
+ pint:
+ name: Code Style
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.3"
+ extensions: mbstring, xml, ctype, json, bcmath, pdo, pgsql
+ tools: composer:v2
+ coverage: none
+ - uses: actions/cache@v4
+ with:
+ path: vendor
+ key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
+ - run: composer install --no-progress --prefer-dist
+ - run: vendor/bin/pint --test
+
+ test:
+ name: Tests (${{ matrix.db }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - db: SQLite
+ connection: sqlite
+ - db: PostgreSQL
+ connection: pgsql
+
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_DB: testing
+ POSTGRES_USER: root
+ POSTGRES_PASSWORD: password
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ env:
+ DB_CONNECTION: ${{ matrix.connection }}
+
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.3"
+ extensions: mbstring, xml, ctype, json, bcmath, pdo, pgsql, sqlite3
+ tools: composer:v2
+ coverage: none
+ - uses: actions/cache@v4
+ with:
+ path: vendor
+ key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
+ - run: composer install --no-progress --prefer-dist
+ - run: cp .env.example .env
+ - run: php artisan key:generate
+ - run: php artisan test
diff --git a/README.md b/README.md
index f86b30c..285355e 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@
+
From 37f4f2c329f57e5676f96ae50a9932971a3f3500 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Fri, 28 Aug 2026 22:49:02 +0330
Subject: [PATCH 18/22] ci: run workflows on PHP 8.5 to match locked
dependencies
---
.github/workflows/ci.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dd7c6ce..d810ad0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
- php-version: "8.3"
+ php-version: "8.5"
extensions: mbstring, xml, ctype, json, bcmath, pdo, pgsql
tools: composer:v2
coverage: none
@@ -59,7 +59,7 @@ jobs:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
- php-version: "8.3"
+ php-version: "8.5"
extensions: mbstring, xml, ctype, json, bcmath, pdo, pgsql, sqlite3
tools: composer:v2
coverage: none
From 03e10be5ed2ca49a2dec4508e32f193c52e9f1ca Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Fri, 28 Aug 2026 23:10:02 +0330
Subject: [PATCH 19/22] ci: run tests against PostgreSQL via per-matrix PHPUnit
config
---
.github/workflows/ci.yml | 4 +++-
phpunit.pgsql.xml | 40 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 43 insertions(+), 1 deletion(-)
create mode 100644 phpunit.pgsql.xml
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d810ad0..2a41eba 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,8 +34,10 @@ jobs:
include:
- db: SQLite
connection: sqlite
+ config: phpunit.xml
- db: PostgreSQL
connection: pgsql
+ config: phpunit.pgsql.xml
services:
postgres:
@@ -70,4 +72,4 @@ jobs:
- run: composer install --no-progress --prefer-dist
- run: cp .env.example .env
- run: php artisan key:generate
- - run: php artisan test
+ - run: php artisan test --configuration=${{ matrix.config }}
diff --git a/phpunit.pgsql.xml b/phpunit.pgsql.xml
new file mode 100644
index 0000000..1257788
--- /dev/null
+++ b/phpunit.pgsql.xml
@@ -0,0 +1,40 @@
+
+
+
+
+ tests/Unit
+
+
+ tests/Feature
+
+
+
+
+ app
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 200d3097795b338b0620528fb6d675d37097f410 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Fri, 28 Aug 2026 23:17:56 +0330
Subject: [PATCH 20/22] ci: run pest directly to avoid duplicate configuration
flag
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2a41eba..93cc554 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,4 +72,4 @@ jobs:
- run: composer install --no-progress --prefer-dist
- run: cp .env.example .env
- run: php artisan key:generate
- - run: php artisan test --configuration=${{ matrix.config }}
+ - run: vendor/bin/pest --configuration=${{ matrix.config }}
From 48192b1729f9c86b68d070b346caa821f85c908f Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Sat, 29 Aug 2026 22:07:00 +0330
Subject: [PATCH 21/22] docs: add changelog
---
CHANGELOG.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 62 insertions(+)
create mode 100644 CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..70092e9
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,62 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/),
+and this project adheres to [Semantic Versioning](https://semver.org/).
+
+## [1.2.0] - 2026-08-28
+
+### Added
+
+- `GET /tasks/stats` endpoint with aggregate counts by status/priority,
+ overdue, due-today, and completed-this-week
+- Sorting support on the task listing via a whitelisted `?sort=` parameter
+ (`-` prefix for descending order)
+- `per_page` pagination control (1–100, default 10)
+- Continuous integration pipeline (Pint + Pest suite, including a PostgreSQL
+ matrix) via GitHub Actions
+- CI status badge in the README
+- Project `color` cast to the `ProjectColor` enum
+
+### Changed
+
+- PHPUnit config split into `phpunit.xml` (SQLite) and `phpunit.pgsql.xml`
+ (PostgreSQL) for the driver matrix
+- Composer package renamed from `laravel/laravel` to `mahdiimax/taskify`
+- Task `status`, `priority`, and `due_date` cast to enums / datetime
+- Task filtering extracted into a reusable `filter()` query scope using
+ driver-aware `whereLike()`
+
+### Fixed
+
+- Delete endpoints return a proper empty `204 No Content` response
+- Redundant manual `Hash::make()` removed in favor of the `hashed` cast
+- Eager loading added to prevent N+1 queries on task resources
+
+## [1.1.0] - 2026-08-14
+
+### Added
+
+- Projects: full CRUD with soft delete & restore
+- Color-coded projects via the `ProjectColor` enum
+- `project_id` on tasks, with filtering by project scoped to the owner
+- Exposed `project_id` in the task resource
+
+## [1.0.0] - 2026-08-06
+
+### Added
+
+- Sanctum token authentication (24h expiry, logout revocation)
+- Full task CRUD with soft delete & restore (`trashed` / `restore`)
+- Task filtering by `status` / `priority` and case-insensitive `search`
+- Per-user task isolation via policies
+- Rate limiting (auth 5/min, API 60/min)
+- Interactive OpenAPI docs via Scramble
+- Pest feature tests: auth, revocation, token expiry, filtering, rate limits
+- PostgreSQL + Docker / pgAdmin setup
+
+[Unreleased]: https://github.com/MahdiiMax/taskify/compare/1.1.0...develop
+[1.2.0]: https://github.com/MahdiiMax/taskify/compare/1.1.0...1.2.0
+[1.1.0]: https://github.com/MahdiiMax/taskify/compare/1.0.0...1.1.0
+[1.0.0]: https://github.com/MahdiiMax/taskify/releases/tag/1.0.0
From 1f1d1fad4197a66beb1609b8aa011c35a0c81e04 Mon Sep 17 00:00:00 2001
From: MahdiiMax
Date: Sat, 29 Aug 2026 22:08:38 +0330
Subject: [PATCH 22/22] docs: refresh README for v1.2.0 features and testing
---
README.md | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 285355e..c6647da 100644
--- a/README.md
+++ b/README.md
@@ -22,10 +22,12 @@
- 🔐 **Sanctum token auth** — 24-hour token expiry, revocable via logout
- 📝 **Full CRUD** with soft delete & restore
-- 🔍 **Smart filtering** — exact `status`/`priority` filters + case-insensitive search
+- 🔍 **Smart filtering** — exact `status`/`priority`/`project_id` filters + case-insensitive search, plus `?sort=` ordering and `?per_page=` pagination
+- 📊 **Statistics** — `GET /tasks/stats` aggregates by status/priority, overdue, due-today, and completed-this-week
- 👤 **Per-user isolation** — tasks and projects are private to their owner
- 📁 **Projects** — organize tasks into color-coded projects with full CRUD, soft delete & restore
- 🚦 **Rate limiting** — auth (5/min) & API (60/min)
+- ✅ **CI pipeline** — Pint style checks + Pest test suite across SQLite & PostgreSQL
- 📚 **Interactive docs** via Scramble
- 🗃️ **PostgreSQL** + Docker setup included
@@ -106,10 +108,10 @@ composer test # or: php artisan test
The suite runs on in-memory SQLite — no DB server needed. ⚠️ *SQLite is more lenient than PostgreSQL; run the suite against real Postgres before release to catch driver-specific issues.*
-If you want to run tests in your own PostgreSQL server:
+Run the suite against a real PostgreSQL server (adjust the credentials in `phpunit.pgsql.xml` to match your database):
```bash
-php artisan test --env=pgsql
+vendor/bin/pest --configuration=phpunit.pgsql.xml
```
@@ -131,6 +133,7 @@ php artisan test --env=pgsql
tests/
├── Feature/Api/V1/ # AuthTest, TaskTest, ProjectTest
+ └── phpunit.pgsql.xml # PostgreSQL test configuration (CI)
## 📜 API Reference