diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0eb0f39..13a8e62 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -49,7 +49,7 @@ jobs:
DB_DATABASE: externals_test
DB_USERNAME: root
DB_PASSWORD: ''
- run: vendor/bin/phpunit --colors=always
+ run: vendor/bin/pest --colors=always
- name: PHPStan
run: vendor/bin/phpstan analyse --memory-limit=1G --no-progress
diff --git a/app/Http/Controllers/LoginController.php b/app/Http/Controllers/Auth/GithubController.php
similarity index 68%
rename from app/Http/Controllers/LoginController.php
rename to app/Http/Controllers/Auth/GithubController.php
index c90d5ee..db870ef 100644
--- a/app/Http/Controllers/LoginController.php
+++ b/app/Http/Controllers/Auth/GithubController.php
@@ -2,9 +2,10 @@
declare(strict_types=1);
-namespace App\Http\Controllers;
+namespace App\Http\Controllers\Auth;
use App\Actions\GetOrCreateUser;
+use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
@@ -12,8 +13,13 @@
use Symfony\Component\HttpFoundation\Response;
use Throwable;
-class LoginController extends Controller
+class GithubController extends Controller
{
+ /**
+ * GitHub redirects back to this same URL after authorization, so this
+ * entry point handles both phases: sending the user to GitHub, and
+ * processing the callback once GitHub returns with a `code`.
+ */
public function __invoke(Request $request): Response
{
if ($request->user()) {
@@ -24,6 +30,14 @@ public function __invoke(Request $request): Response
return Socialite::driver('github')->redirect();
}
+ return $this->handleCallback($request);
+ }
+
+ /**
+ * Handle the callback from GitHub after authorization.
+ */
+ private function handleCallback(Request $request): Response
+ {
try {
$githubUser = Socialite::driver('github')->user();
} catch (InvalidStateException) {
diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php
deleted file mode 100644
index ed64fe3..0000000
--- a/app/Http/Controllers/HomeController.php
+++ /dev/null
@@ -1,38 +0,0 @@
-user();
- $page = (int) $request->query('page', 1);
- $perPage = 20;
-
- $threadCount = Cache::remember(
- 'stats.threadCount',
- now()->addMinutes(5),
- fn() => Email::where('isThreadRoot', true)->count(),
- );
-
- return view('home', [
- 'threads' => $this->threads->findLatestThreads($page, $user),
- 'page' => $page,
- 'pageCount' => (int) ceil($threadCount / $perPage),
- 'user' => $user,
- ]);
- }
-}
diff --git a/app/Http/Controllers/NewsController.php b/app/Http/Controllers/NewsController.php
deleted file mode 100644
index b3ed6aa..0000000
--- a/app/Http/Controllers/NewsController.php
+++ /dev/null
@@ -1,16 +0,0 @@
- $request->user()]);
- }
-}
diff --git a/app/Http/Controllers/StatsController.php b/app/Http/Controllers/StatsController.php
deleted file mode 100644
index d30f4d4..0000000
--- a/app/Http/Controllers/StatsController.php
+++ /dev/null
@@ -1,26 +0,0 @@
-addMinutes(5);
-
- return view('stats', [
- 'userCount' => Cache::remember('stats.userCount', $ttl, fn() => User::count()),
- 'threadCount' => Cache::remember('stats.threadCount', $ttl, fn() => Email::where('isThreadRoot', true)->count()),
- 'emailCount' => Cache::remember('stats.emailCount', $ttl, fn() => Email::count()),
- 'user' => $request->user(),
- ]);
- }
-}
diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php
deleted file mode 100644
index b06833c..0000000
--- a/app/Http/Controllers/ThreadController.php
+++ /dev/null
@@ -1,50 +0,0 @@
-first();
- if (! $email) {
- throw new NotFoundException("Email $number was not found");
- }
-
- if (! $email->isThreadRoot()) {
- $thread = Email::find($email->threadId);
- if ($thread) {
- return redirect("/message/{$thread->number}#$number");
- }
- // Root message not found — render the thread from this URL anyway
- }
-
- $user = $request->user();
- // Build the thread view BEFORE marking the thread as read
- $threadView = $this->threads->getThreadView($email, $user);
- if ($user) {
- app(MarkEmailAsRead::class)->handle($email, $user);
- }
-
- return response()->view('thread', [
- 'subject' => $email->subject,
- 'thread' => $threadView,
- 'threadId' => $number,
- 'user' => $user,
- ]);
- }
-}
diff --git a/app/Http/Controllers/TopController.php b/app/Http/Controllers/TopController.php
deleted file mode 100644
index 703bbf5..0000000
--- a/app/Http/Controllers/TopController.php
+++ /dev/null
@@ -1,26 +0,0 @@
-user();
-
- return view('top', [
- 'threads' => $this->threads->findTopThreads(1, $user),
- 'user' => $user,
- ]);
- }
-}
diff --git a/app/Http/Controllers/VoteController.php b/app/Http/Controllers/VoteController.php
deleted file mode 100644
index d33c5b3..0000000
--- a/app/Http/Controllers/VoteController.php
+++ /dev/null
@@ -1,30 +0,0 @@
-user();
- if (! $user) {
- return response()->json('You must be authenticated', 401);
- }
-
- $vote = (int) $request->input('value', 0);
- if ($vote > 1 || $vote < -1) {
- return response()->json('Invalid value', 400);
- }
-
- return response()->json([
- 'newTotal' => app(CastVote::class)->handle($user->id, $number, $vote),
- 'newValue' => $vote,
- ]);
- }
-}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 3fef439..a388f29 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -10,7 +10,6 @@
use App\Services\Search\AlgoliaSearchIndex;
use App\Services\Search\ReadOnlySearchIndex;
use App\Services\Search\SearchIndex;
-use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use League\CommonMark\CommonMarkConverter;
use VStelmakh\UrlHighlight\Highlighter\HtmlHighlighter;
@@ -59,12 +58,5 @@ public function register(): void
$this->app->singleton(RssRfcBuilder::class, fn() => new RssRfcBuilder((string) config('externals.rss_host')));
}
- public function boot(): void
- {
- View::share('version', (string) config('externals.version'));
- View::share('assetsBaseUrl', (string) config('externals.assets_base_url'));
- View::share('algoliaIndex', config('services.algolia.index_prefix') . 'emails');
- View::share('noIndex', (bool) config('externals.no_index'));
- View::share('debug', (bool) config('app.debug'));
- }
+ public function boot(): void {}
}
diff --git a/app/Providers/VoltServiceProvider.php b/app/Providers/VoltServiceProvider.php
new file mode 100644
index 0000000..8b761d0
--- /dev/null
+++ b/app/Providers/VoltServiceProvider.php
@@ -0,0 +1,25 @@
+>
+ * @return ThreadSummary[]
*/
public function findLatestThreads(int $page, ?User $user): array
{
- return $this->findThreads('', 'ORDER BY threads.lastUpdate DESC', $page, $user);
+ return $this->findThreads('', [], 'ORDER BY threads.lastUpdate DESC', $page, $user);
}
/**
- * @return array>
+ * @return ThreadSummary[]
*/
public function findTopThreads(int $page, ?User $user): array
{
- $where = 'WHERE threads.votes > 0 AND threads.lastUpdate > DATE_SUB(NOW(), INTERVAL 1 MONTH)';
+ $where = 'WHERE threads.votes > 0 AND threads.lastUpdate > ?';
+ $oneMonthAgo = now()->subMonth()->toDateTimeString();
- return $this->findThreads($where, 'ORDER BY threads.votes DESC, threads.lastUpdate DESC', $page, $user);
+ return $this->findThreads($where, [$oneMonthAgo], 'ORDER BY threads.votes DESC, threads.lastUpdate DESC', $page, $user);
}
/**
- * @return array>
+ * @return ThreadSummary[]
*/
public function findLatestRfcThreads(): array
{
- return $this->findThreads("WHERE threadInfos.subject LIKE '%RFC%'", 'ORDER BY threadInfos.date DESC', 1, null);
+ return $this->findThreads("WHERE threadInfos.subject LIKE '%RFC%'", [], 'ORDER BY threadInfos.date DESC', 1, null);
}
/**
@@ -94,9 +96,13 @@ public function getThreadView(Email $email, ?User $user = null): array
}
/**
- * @return array>
+ * @return ThreadSummary[]
*/
- private function findThreads(string $where, string $orderBy, int $page, ?User $user): array
+ /**
+ * @param list $whereBindings Bindings for any `?` placeholders in $where.
+ * @return ThreadSummary[]
+ */
+ private function findThreads(string $where, array $whereBindings, string $orderBy, int $page, ?User $user): array
{
$page = max(1, $page);
$offset = ($page - 1) * 20;
@@ -121,7 +127,7 @@ private function findThreads(string $where, string $orderBy, int $page, ?User $u
$orderBy
LIMIT 20 OFFSET $offset
SQL;
- $parameters = [$user->id, $user->id];
+ $parameters = [$user->id, $user->id, ...$whereBindings];
} else {
$query = << (array) $row, DB::select($query, $parameters));
+ return array_map(ThreadSummary::fromRow(...), DB::select($query, $parameters));
}
}
diff --git a/app/Services/Rss/RssRfcBuilder.php b/app/Services/Rss/RssRfcBuilder.php
index ba80b5d..a13b140 100644
--- a/app/Services/Rss/RssRfcBuilder.php
+++ b/app/Services/Rss/RssRfcBuilder.php
@@ -4,7 +4,7 @@
namespace App\Services\Rss;
-use DateTimeImmutable;
+use App\Support\Email\ThreadSummary;
use DomDocument;
use DomElement;
use RuntimeException;
@@ -18,7 +18,7 @@ public function __construct(
) {}
/**
- * @param array> $threads
+ * @param ThreadSummary[] $threads
*/
public function build(array $threads): string
{
@@ -40,11 +40,11 @@ public function build(array $threads): string
foreach ($threads as $thread) {
$item = $this->dom->createElement('item');
- $this->addTextNode('title', $thread['subject'], $item);
- $this->addTextNode('link', $this->host . '/message/' . $thread['number'], $item);
- $this->addTextNode('description', $thread['subject'], $item);
- $this->addTextNode('guid', (string) $thread['number'], $item);
- $this->addTextNode('pubDate', (new DateTimeImmutable($thread['date']))->format('r'), $item);
+ $this->addTextNode('title', $thread->subject, $item);
+ $this->addTextNode('link', $this->host . '/message/' . $thread->number, $item);
+ $this->addTextNode('description', $thread->subject, $item);
+ $this->addTextNode('guid', (string) $thread->number, $item);
+ $this->addTextNode('pubDate', $thread->date->format('r'), $item);
$channel->appendChild($item);
}
diff --git a/app/Support/Email/ThreadSummary.php b/app/Support/Email/ThreadSummary.php
new file mode 100644
index 0000000..cf97cca
--- /dev/null
+++ b/app/Support/Email/ThreadSummary.php
@@ -0,0 +1,46 @@
+number,
+ subject: (string) $row->subject,
+ date: CarbonImmutable::parse($row->date),
+ fromName: $row->fromName !== null ? (string) $row->fromName : null,
+ fromEmail: isset($row->fromEmail) ? (string) $row->fromEmail : null,
+ emailCount: (int) $row->emailCount,
+ lastUpdate: CarbonImmutable::parse($row->lastUpdate),
+ votes: (int) $row->votes,
+ isRead: (bool) $row->isRead,
+ userVote: $row->userVote !== null ? (int) $row->userVote : null,
+ );
+ }
+}
diff --git a/bootstrap/providers.php b/bootstrap/providers.php
index fc94ae6..7d50f93 100644
--- a/bootstrap/providers.php
+++ b/bootstrap/providers.php
@@ -1,7 +1,6 @@
=0.8.16 <=0.18",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@@ -4890,9 +5036,9 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
- "source": "https://github.com/ramsey/uuid/tree/4.9.2"
+ "source": "https://github.com/ramsey/uuid/tree/4.9.3"
},
- "time": "2025-12-14T04:43:48+00:00"
+ "time": "2026-06-18T03:57:49+00:00"
},
{
"name": "riverline/multipart-parser",
@@ -5215,16 +5361,16 @@
},
{
"name": "symfony/console",
- "version": "v8.1.0",
+ "version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a"
+ "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a",
- "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a",
+ "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d",
+ "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d",
"shasum": ""
},
"require": {
@@ -5291,7 +5437,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v8.1.0"
+ "source": "https://github.com/symfony/console/tree/v8.1.1"
},
"funding": [
{
@@ -5311,7 +5457,7 @@
"type": "tidelift"
}
],
- "time": "2026-05-29T05:06:50+00:00"
+ "time": "2026-06-16T12:55:20+00:00"
},
{
"name": "symfony/css-selector",
@@ -5384,16 +5530,16 @@
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.7.0",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
- "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": ""
},
"require": {
@@ -5431,7 +5577,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -5451,7 +5597,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-13T15:52:40+00:00"
+ "time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/error-handler",
@@ -5536,16 +5682,16 @@
},
{
"name": "symfony/event-dispatcher",
- "version": "v8.1.0",
+ "version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102"
+ "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102",
- "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0",
+ "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0",
"shasum": ""
},
"require": {
@@ -5598,7 +5744,7 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0"
+ "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1"
},
"funding": [
{
@@ -5618,20 +5764,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-29T05:06:50+00:00"
+ "time": "2026-06-09T12:28:30+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
- "version": "v3.7.0",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
- "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32"
+ "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32",
- "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e",
+ "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e",
"shasum": ""
},
"require": {
@@ -5678,7 +5824,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0"
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -5698,7 +5844,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-05T13:30:16+00:00"
+ "time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/filesystem",
@@ -5773,16 +5919,16 @@
},
{
"name": "symfony/finder",
- "version": "v8.1.0",
+ "version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "58d2e767a66052c1487356f953445634a8194c64"
+ "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64",
- "reference": "58d2e767a66052c1487356f953445634a8194c64",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/e2989e762c70f9490fa3a00a0ac0fae5aa97a531",
+ "reference": "e2989e762c70f9490fa3a00a0ac0fae5aa97a531",
"shasum": ""
},
"require": {
@@ -5817,7 +5963,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v8.1.0"
+ "source": "https://github.com/symfony/finder/tree/v8.1.1"
},
"funding": [
{
@@ -5837,20 +5983,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-29T05:06:50+00:00"
+ "time": "2026-06-27T09:05:56+00:00"
},
{
"name": "symfony/http-foundation",
- "version": "v8.1.0",
+ "version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e"
+ "reference": "6a168c8fcee806b57ac020244da14293d1f9a883"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e",
- "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6a168c8fcee806b57ac020244da14293d1f9a883",
+ "reference": "6a168c8fcee806b57ac020244da14293d1f9a883",
"shasum": ""
},
"require": {
@@ -5898,7 +6044,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-foundation/tree/v8.1.0"
+ "source": "https://github.com/symfony/http-foundation/tree/v8.1.1"
},
"funding": [
{
@@ -5918,20 +6064,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-29T05:06:50+00:00"
+ "time": "2026-06-12T08:43:41+00:00"
},
{
"name": "symfony/http-kernel",
- "version": "v8.1.0",
+ "version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
- "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39"
+ "reference": "89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39",
- "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39",
+ "url": "https://api.github.com/repos/symfony/http-kernel/zipball/89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2",
+ "reference": "89d8d6e7fbab3d9eda89ccb5ecdf44a74c4ec9d2",
"shasum": ""
},
"require": {
@@ -6007,7 +6153,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-kernel/tree/v8.1.0"
+ "source": "https://github.com/symfony/http-kernel/tree/v8.1.1"
},
"funding": [
{
@@ -6027,20 +6173,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-29T08:46:08+00:00"
+ "time": "2026-06-27T09:27:36+00:00"
},
{
"name": "symfony/mailer",
- "version": "v8.1.0",
+ "version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
- "reference": "9418d772df3a03a142e3bc06f602adb2b8724877"
+ "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mailer/zipball/9418d772df3a03a142e3bc06f602adb2b8724877",
- "reference": "9418d772df3a03a142e3bc06f602adb2b8724877",
+ "url": "https://api.github.com/repos/symfony/mailer/zipball/4fa583a7377f28d54e4de442fba76375b2e20a12",
+ "reference": "4fa583a7377f28d54e4de442fba76375b2e20a12",
"shasum": ""
},
"require": {
@@ -6087,7 +6233,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/mailer/tree/v8.1.0"
+ "source": "https://github.com/symfony/mailer/tree/v8.1.1"
},
"funding": [
{
@@ -6107,7 +6253,7 @@
"type": "tidelift"
}
],
- "time": "2026-05-29T05:06:50+00:00"
+ "time": "2026-06-16T12:55:20+00:00"
},
{
"name": "symfony/mime",
@@ -7478,16 +7624,16 @@
},
{
"name": "symfony/service-contracts",
- "version": "v3.7.0",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a"
+ "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a",
- "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0",
+ "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0",
"shasum": ""
},
"require": {
@@ -7541,7 +7687,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.7.0"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -7561,7 +7707,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-28T09:44:51+00:00"
+ "time": "2026-06-16T09:55:08+00:00"
},
{
"name": "symfony/string",
@@ -7655,16 +7801,16 @@
},
{
"name": "symfony/translation",
- "version": "v8.1.0",
+ "version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
- "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693"
+ "reference": "342b4218630dc2cf284cedcb2080c80b13404014"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693",
- "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693",
+ "url": "https://api.github.com/repos/symfony/translation/zipball/342b4218630dc2cf284cedcb2080c80b13404014",
+ "reference": "342b4218630dc2cf284cedcb2080c80b13404014",
"shasum": ""
},
"require": {
@@ -7724,7 +7870,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/translation/tree/v8.1.0"
+ "source": "https://github.com/symfony/translation/tree/v8.1.1"
},
"funding": [
{
@@ -7744,20 +7890,20 @@
"type": "tidelift"
}
],
- "time": "2026-05-29T05:06:50+00:00"
+ "time": "2026-06-06T11:11:44+00:00"
},
{
"name": "symfony/translation-contracts",
- "version": "v3.7.0",
+ "version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
- "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d"
+ "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d",
- "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d",
+ "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621",
+ "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621",
"shasum": ""
},
"require": {
@@ -7806,7 +7952,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0"
+ "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1"
},
"funding": [
{
@@ -7826,7 +7972,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-05T13:30:16+00:00"
+ "time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/uid",
@@ -7908,16 +8054,16 @@
},
{
"name": "symfony/var-dumper",
- "version": "v8.1.0",
+ "version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
- "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e"
+ "reference": "40096a2515a979f3125c5c928603995b8664c62a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e",
- "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e",
+ "url": "https://api.github.com/repos/symfony/var-dumper/zipball/40096a2515a979f3125c5c928603995b8664c62a",
+ "reference": "40096a2515a979f3125c5c928603995b8664c62a",
"shasum": ""
},
"require": {
@@ -7971,7 +8117,7 @@
"dump"
],
"support": {
- "source": "https://github.com/symfony/var-dumper/tree/v8.1.0"
+ "source": "https://github.com/symfony/var-dumper/tree/v8.1.1"
},
"funding": [
{
@@ -7991,7 +8137,7 @@
"type": "tidelift"
}
],
- "time": "2026-05-29T05:06:50+00:00"
+ "time": "2026-06-09T10:54:51+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
@@ -8476,45 +8622,56 @@
],
"packages-dev": [
{
- "name": "fakerphp/faker",
- "version": "v1.24.1",
+ "name": "brianium/paratest",
+ "version": "v7.20.0",
"source": {
"type": "git",
- "url": "https://github.com/FakerPHP/Faker.git",
- "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5"
+ "url": "https://github.com/paratestphp/paratest.git",
+ "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
- "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+ "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
+ "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"shasum": ""
},
"require": {
- "php": "^7.4 || ^8.0",
- "psr/container": "^1.0 || ^2.0",
- "symfony/deprecation-contracts": "^2.2 || ^3.0"
- },
- "conflict": {
- "fzaninotto/faker": "*"
+ "ext-dom": "*",
+ "ext-pcre": "*",
+ "ext-reflection": "*",
+ "ext-simplexml": "*",
+ "fidry/cpu-core-counter": "^1.3.0",
+ "jean85/pretty-package-versions": "^2.1.1",
+ "php": "~8.3.0 || ~8.4.0 || ~8.5.0",
+ "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1",
+ "phpunit/php-file-iterator": "^6.0.1 || ^7",
+ "phpunit/php-timer": "^8 || ^9",
+ "phpunit/phpunit": "^12.5.14 || ^13.0.5",
+ "sebastian/environment": "^8.0.3 || ^9",
+ "symfony/console": "^7.4.7 || ^8.0.7",
+ "symfony/process": "^7.4.5 || ^8.0.5"
},
"require-dev": {
- "bamarni/composer-bin-plugin": "^1.4.1",
- "doctrine/persistence": "^1.3 || ^2.0",
- "ext-intl": "*",
- "phpunit/phpunit": "^9.5.26",
- "symfony/phpunit-bridge": "^5.4.16"
- },
- "suggest": {
- "doctrine/orm": "Required to use Faker\\ORM\\Doctrine",
- "ext-curl": "Required by Faker\\Provider\\Image to download images.",
- "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.",
- "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.",
- "ext-mbstring": "Required for multibyte Unicode string functionality."
+ "doctrine/coding-standard": "^14.0.0",
+ "ext-pcntl": "*",
+ "ext-pcov": "*",
+ "ext-posix": "*",
+ "phpstan/phpstan": "^2.1.44",
+ "phpstan/phpstan-deprecation-rules": "^2.0.4",
+ "phpstan/phpstan-phpunit": "^2.0.16",
+ "phpstan/phpstan-strict-rules": "^2.0.10",
+ "symfony/filesystem": "^7.4.6 || ^8.0.6"
},
+ "bin": [
+ "bin/paratest",
+ "bin/paratest_for_phpstorm"
+ ],
"type": "library",
"autoload": {
"psr-4": {
- "Faker\\": "src/Faker/"
+ "ParaTest\\": [
+ "src/"
+ ]
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -8523,57 +8680,79 @@
],
"authors": [
{
- "name": "François Zaninotto"
+ "name": "Brian Scaturro",
+ "email": "scaturrob@gmail.com",
+ "role": "Developer"
+ },
+ {
+ "name": "Filippo Tessarotto",
+ "email": "zoeslam@gmail.com",
+ "role": "Developer"
}
],
- "description": "Faker is a PHP library that generates fake data for you.",
+ "description": "Parallel testing for PHP",
+ "homepage": "https://github.com/paratestphp/paratest",
"keywords": [
- "data",
- "faker",
- "fixtures"
+ "concurrent",
+ "parallel",
+ "phpunit",
+ "testing"
],
"support": {
- "issues": "https://github.com/FakerPHP/Faker/issues",
- "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1"
+ "issues": "https://github.com/paratestphp/paratest/issues",
+ "source": "https://github.com/paratestphp/paratest/tree/v7.20.0"
},
- "time": "2024-11-21T13:46:39+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/Slamdunk",
+ "type": "github"
+ },
+ {
+ "url": "https://paypal.me/filippotessarotto",
+ "type": "paypal"
+ }
+ ],
+ "time": "2026-03-29T15:46:14+00:00"
},
{
- "name": "filp/whoops",
- "version": "2.18.4",
+ "name": "composer/pcre",
+ "version": "3.3.2",
"source": {
"type": "git",
- "url": "https://github.com/filp/whoops.git",
- "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
- "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
- "php": "^7.1 || ^8.0",
- "psr/log": "^1.0.1 || ^2.0 || ^3.0"
+ "php": "^7.4 || ^8.0"
},
- "require-dev": {
- "mockery/mockery": "^1.0",
- "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3",
- "symfony/var-dumper": "^4.0 || ^5.0"
+ "conflict": {
+ "phpstan/phpstan": "<1.11.10"
},
- "suggest": {
- "symfony/var-dumper": "Pretty print complex values better with var-dumper available",
- "whoops/soap": "Formats errors as SOAP responses"
+ "require-dev": {
+ "phpstan/phpstan": "^1.12 || ^2",
+ "phpstan/phpstan-strict-rules": "^1 || ^2",
+ "phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
"branch-alias": {
- "dev-master": "2.7-dev"
+ "dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
- "Whoops\\": "src/Whoops/"
+ "Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -8582,180 +8761,870 @@
],
"authors": [
{
- "name": "Filipe Dobreira",
- "homepage": "https://github.com/filp",
- "role": "Developer"
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
}
],
- "description": "php error handling for cool kids",
- "homepage": "https://filp.github.io/whoops/",
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
- "error",
- "exception",
- "handling",
- "library",
- "throwable",
- "whoops"
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
],
"support": {
- "issues": "https://github.com/filp/whoops/issues",
- "source": "https://github.com/filp/whoops/tree/2.18.4"
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
- "url": "https://github.com/denis-sokolov",
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
"type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
}
],
- "time": "2025-08-08T12:00:00+00:00"
+ "time": "2024-11-12T16:29:46+00:00"
},
{
- "name": "hamcrest/hamcrest-php",
- "version": "v2.1.1",
+ "name": "composer/xdebug-handler",
+ "version": "3.0.5",
"source": {
"type": "git",
- "url": "https://github.com/hamcrest/hamcrest-php.git",
- "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487"
+ "url": "https://github.com/composer/xdebug-handler.git",
+ "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
- "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
+ "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef",
+ "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef",
"shasum": ""
},
"require": {
- "php": "^7.4|^8.0"
- },
- "replace": {
- "cordoval/hamcrest-php": "*",
- "davedevelopment/hamcrest-php": "*",
- "kodova/hamcrest-php": "*"
+ "composer/pcre": "^1 || ^2 || ^3",
+ "php": "^7.2.5 || ^8.0",
+ "psr/log": "^1 || ^2 || ^3"
},
"require-dev": {
- "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0",
- "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0"
+ "phpstan/phpstan": "^1.0",
+ "phpstan/phpstan-strict-rules": "^1.1",
+ "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5"
},
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.1-dev"
- }
- },
"autoload": {
- "classmap": [
- "hamcrest"
- ]
+ "psr-4": {
+ "Composer\\XdebugHandler\\": "src"
+ }
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "BSD-3-Clause"
+ "MIT"
],
- "description": "This is the PHP port of Hamcrest Matchers",
+ "authors": [
+ {
+ "name": "John Stevenson",
+ "email": "john-stevenson@blueyonder.co.uk"
+ }
+ ],
+ "description": "Restarts a process without Xdebug.",
"keywords": [
- "test"
+ "Xdebug",
+ "performance"
],
"support": {
- "issues": "https://github.com/hamcrest/hamcrest-php/issues",
- "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1"
+ "irc": "ircs://irc.libera.chat:6697/composer",
+ "issues": "https://github.com/composer/xdebug-handler/issues",
+ "source": "https://github.com/composer/xdebug-handler/tree/3.0.5"
},
- "time": "2025-04-30T06:54:44+00:00"
- },
- {
- "name": "iamcal/sql-parser",
- "version": "v0.7",
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-05-06T16:37:16+00:00"
+ },
+ {
+ "name": "doctrine/deprecations",
+ "version": "1.1.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/deprecations.git",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<=7.5 || >=14"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^12 || ^14",
+ "phpstan/phpstan": "1.4.10 || 2.1.30",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
+ "psr/log": "^1 || ^2 || ^3"
+ },
+ "suggest": {
+ "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Deprecations\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+ "homepage": "https://www.doctrine-project.org/",
+ "support": {
+ "issues": "https://github.com/doctrine/deprecations/issues",
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
+ },
+ "time": "2026-02-07T07:09:04+00:00"
+ },
+ {
+ "name": "fakerphp/faker",
+ "version": "v1.24.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/FakerPHP/Faker.git",
+ "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+ "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "psr/container": "^1.0 || ^2.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "conflict": {
+ "fzaninotto/faker": "*"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.4.1",
+ "doctrine/persistence": "^1.3 || ^2.0",
+ "ext-intl": "*",
+ "phpunit/phpunit": "^9.5.26",
+ "symfony/phpunit-bridge": "^5.4.16"
+ },
+ "suggest": {
+ "doctrine/orm": "Required to use Faker\\ORM\\Doctrine",
+ "ext-curl": "Required by Faker\\Provider\\Image to download images.",
+ "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.",
+ "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.",
+ "ext-mbstring": "Required for multibyte Unicode string functionality."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Faker\\": "src/Faker/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "François Zaninotto"
+ }
+ ],
+ "description": "Faker is a PHP library that generates fake data for you.",
+ "keywords": [
+ "data",
+ "faker",
+ "fixtures"
+ ],
+ "support": {
+ "issues": "https://github.com/FakerPHP/Faker/issues",
+ "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1"
+ },
+ "time": "2024-11-21T13:46:39+00:00"
+ },
+ {
+ "name": "fidry/cpu-core-counter",
+ "version": "1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theofidry/cpu-core-counter.git",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "fidry/makefile": "^0.2.0",
+ "fidry/php-cs-fixer-config": "^1.1.2",
+ "phpstan/extension-installer": "^1.2.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-deprecation-rules": "^2.0.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+ "webmozarts/strict-phpunit": "^7.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Fidry\\CpuCoreCounter\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Théo FIDRY",
+ "email": "theo.fidry@gmail.com"
+ }
+ ],
+ "description": "Tiny utility to get the number of CPU cores.",
+ "keywords": [
+ "CPU",
+ "core"
+ ],
+ "support": {
+ "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+ "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theofidry",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-14T07:29:31+00:00"
+ },
+ {
+ "name": "filp/whoops",
+ "version": "2.18.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/filp/whoops.git",
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0",
+ "psr/log": "^1.0.1 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.0",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3",
+ "symfony/var-dumper": "^4.0 || ^5.0"
+ },
+ "suggest": {
+ "symfony/var-dumper": "Pretty print complex values better with var-dumper available",
+ "whoops/soap": "Formats errors as SOAP responses"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Whoops\\": "src/Whoops/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Filipe Dobreira",
+ "homepage": "https://github.com/filp",
+ "role": "Developer"
+ }
+ ],
+ "description": "php error handling for cool kids",
+ "homepage": "https://filp.github.io/whoops/",
+ "keywords": [
+ "error",
+ "exception",
+ "handling",
+ "library",
+ "throwable",
+ "whoops"
+ ],
+ "support": {
+ "issues": "https://github.com/filp/whoops/issues",
+ "source": "https://github.com/filp/whoops/tree/2.18.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/denis-sokolov",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-08T12:00:00+00:00"
+ },
+ {
+ "name": "hamcrest/hamcrest-php",
+ "version": "v2.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/hamcrest/hamcrest-php.git",
+ "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
+ "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4|^8.0"
+ },
+ "replace": {
+ "cordoval/hamcrest-php": "*",
+ "davedevelopment/hamcrest-php": "*",
+ "kodova/hamcrest-php": "*"
+ },
+ "require-dev": {
+ "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0",
+ "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "hamcrest"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "description": "This is the PHP port of Hamcrest Matchers",
+ "keywords": [
+ "test"
+ ],
+ "support": {
+ "issues": "https://github.com/hamcrest/hamcrest-php/issues",
+ "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1"
+ },
+ "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": "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/pail",
+ "version": "v1.2.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/pail.git",
+ "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa",
+ "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "illuminate/console": "^10.24|^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0",
+ "illuminate/log": "^10.24|^11.0|^12.0|^13.0",
+ "illuminate/process": "^10.24|^11.0|^12.0|^13.0",
+ "illuminate/support": "^10.24|^11.0|^12.0|^13.0",
+ "nunomaduro/termwind": "^1.15|^2.0",
+ "php": "^8.2",
+ "symfony/console": "^6.0|^7.0|^8.0"
+ },
+ "require-dev": {
+ "laravel/framework": "^10.24|^11.0|^12.0|^13.0",
+ "laravel/pint": "^1.13",
+ "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0",
+ "pestphp/pest": "^2.20|^3.0|^4.0",
+ "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0",
+ "phpstan/phpstan": "^1.12.27",
+ "symfony/var-dumper": "^6.3|^7.0|^8.0",
+ "symfony/yaml": "^6.3|^7.0|^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Pail\\PailServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Pail\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ },
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "Easily delve into your Laravel application's log files directly from the command line.",
+ "homepage": "https://github.com/laravel/pail",
+ "keywords": [
+ "dev",
+ "laravel",
+ "logs",
+ "php",
+ "tail"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/pail/issues",
+ "source": "https://github.com/laravel/pail"
+ },
+ "time": "2026-05-20T22:24:57+00:00"
+ },
+ {
+ "name": "laravel/pint",
+ "version": "v1.29.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/pint.git",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "ext-tokenizer": "*",
+ "ext-xml": "*",
+ "php": "^8.2.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.95.1",
+ "illuminate/view": "^12.56.0",
+ "larastan/larastan": "^3.9.6",
+ "laravel-zero/framework": "^12.1.0",
+ "mockery/mockery": "^1.6.12",
+ "nunomaduro/termwind": "^2.4.0",
+ "pestphp/pest": "^3.8.6",
+ "shipfastlabs/agent-detector": "^1.1.3"
+ },
+ "bin": [
+ "builds/pint"
+ ],
+ "type": "project",
+ "autoload": {
+ "psr-4": {
+ "App\\": "app/",
+ "Database\\Seeders\\": "database/seeders/",
+ "Database\\Factories\\": "database/factories/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "An opinionated code formatter for PHP.",
+ "homepage": "https://laravel.com",
+ "keywords": [
+ "dev",
+ "format",
+ "formatter",
+ "lint",
+ "linter",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/pint/issues",
+ "source": "https://github.com/laravel/pint"
+ },
+ "time": "2026-04-20T15:26:14+00:00"
+ },
+ {
+ "name": "mockery/mockery",
+ "version": "1.6.12",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/mockery/mockery.git",
+ "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699",
+ "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699",
+ "shasum": ""
+ },
+ "require": {
+ "hamcrest/hamcrest-php": "^2.0.1",
+ "lib-pcre": ">=7.0",
+ "php": ">=7.3"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5 || ^9.6.17",
+ "symplify/easy-coding-standard": "^12.1.14"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "library/helpers.php",
+ "library/Mockery.php"
+ ],
+ "psr-4": {
+ "Mockery\\": "library/Mockery"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Pádraic Brady",
+ "email": "padraic.brady@gmail.com",
+ "homepage": "https://github.com/padraic",
+ "role": "Author"
+ },
+ {
+ "name": "Dave Marshall",
+ "email": "dave.marshall@atstsolutions.co.uk",
+ "homepage": "https://davedevelopment.co.uk",
+ "role": "Developer"
+ },
+ {
+ "name": "Nathanael Esayeas",
+ "email": "nathanael.esayeas@protonmail.com",
+ "homepage": "https://github.com/ghostwriter",
+ "role": "Lead Developer"
+ }
+ ],
+ "description": "Mockery is a simple yet flexible PHP mock object framework",
+ "homepage": "https://github.com/mockery/mockery",
+ "keywords": [
+ "BDD",
+ "TDD",
+ "library",
+ "mock",
+ "mock objects",
+ "mockery",
+ "stub",
+ "test",
+ "test double",
+ "testing"
+ ],
+ "support": {
+ "docs": "https://docs.mockery.io/",
+ "issues": "https://github.com/mockery/mockery/issues",
+ "rss": "https://github.com/mockery/mockery/releases.atom",
+ "security": "https://github.com/mockery/mockery/security/advisories",
+ "source": "https://github.com/mockery/mockery"
+ },
+ "time": "2024-05-16T03:13:13+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
"source": {
"type": "git",
- "url": "https://github.com/iamcal/SQLParser.git",
- "reference": "610392f38de49a44dab08dc1659960a29874c4b8"
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8",
- "reference": "610392f38de49a44dab08dc1659960a29874c4b8",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
"require-dev": {
- "php-coveralls/php-coveralls": "^1.0",
- "phpunit/phpunit": "^5|^6|^7|^8|^9"
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
},
"type": "library",
"autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
"psr-4": {
- "iamcal\\": "src"
+ "DeepCopy\\": "src/DeepCopy/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
- "authors": [
- {
- "name": "Cal Henderson",
- "email": "cal@iamcal.com"
- }
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
],
- "description": "MySQL schema parser",
"support": {
- "issues": "https://github.com/iamcal/SQLParser/issues",
- "source": "https://github.com/iamcal/SQLParser/tree/v0.7"
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
},
- "time": "2026-01-28T22:20:33+00:00"
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
},
{
- "name": "larastan/larastan",
- "version": "v3.10.0",
+ "name": "nunomaduro/collision",
+ "version": "v8.9.4",
"source": {
"type": "git",
- "url": "https://github.com/larastan/larastan.git",
- "reference": "2970f83398154178a739609c244577267c7ee8eb"
+ "url": "https://github.com/nunomaduro/collision.git",
+ "reference": "716af8f95a470e9094cfca09ed897b023be191a5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb",
- "reference": "2970f83398154178a739609c244577267c7ee8eb",
+ "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5",
+ "reference": "716af8f95a470e9094cfca09ed897b023be191a5",
"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"
+ "filp/whoops": "^2.18.4",
+ "nunomaduro/termwind": "^2.4.0",
+ "php": "^8.2.0",
+ "symfony/console": "^7.4.8 || ^8.0.8"
},
- "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"
+ "conflict": {
+ "laravel/framework": "<11.48.0 || >=14.0.0",
+ "phpunit/phpunit": "<11.5.50 || >=14.0.0"
},
- "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"
+ "require-dev": {
+ "brianium/paratest": "^7.8.5",
+ "larastan/larastan": "^3.9.6",
+ "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0",
+ "laravel/pint": "^1.29.1",
+ "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1",
+ "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0",
+ "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0"
},
- "type": "phpstan-extension",
+ "type": "library",
"extra": {
- "phpstan": {
- "includes": [
- "extension.neon"
+ "laravel": {
+ "providers": [
+ "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider"
]
},
"branch-alias": {
- "dev-master": "3.0-dev"
+ "dev-8.x": "8.x-dev"
}
},
"autoload": {
+ "files": [
+ "./src/Adapters/Phpunit/Autoload.php"
+ ],
"psr-4": {
- "Larastan\\Larastan\\": "src/"
+ "NunoMaduro\\Collision\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -8764,82 +9633,126 @@
],
"authors": [
{
- "name": "Can Vural",
- "email": "can9119@gmail.com"
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
}
],
- "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel",
+ "description": "Cli error handling for console/command-line PHP applications.",
"keywords": [
- "PHPStan",
- "code analyse",
- "code analysis",
- "larastan",
+ "artisan",
+ "cli",
+ "command-line",
+ "console",
+ "dev",
+ "error",
+ "handling",
"laravel",
- "package",
+ "laravel-zero",
"php",
- "static analysis"
+ "symfony"
],
"support": {
- "issues": "https://github.com/larastan/larastan/issues",
- "source": "https://github.com/larastan/larastan/tree/v3.10.0"
+ "issues": "https://github.com/nunomaduro/collision/issues",
+ "source": "https://github.com/nunomaduro/collision"
},
"funding": [
{
- "url": "https://github.com/canvural",
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
"type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/nunomaduro",
+ "type": "patreon"
}
],
- "time": "2026-05-28T08:00:58+00:00"
+ "time": "2026-04-21T14:04:20+00:00"
},
{
- "name": "laravel/pail",
- "version": "v1.2.7",
+ "name": "pestphp/pest",
+ "version": "v4.7.4",
"source": {
"type": "git",
- "url": "https://github.com/laravel/pail.git",
- "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa"
+ "url": "https://github.com/pestphp/pest.git",
+ "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa",
- "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa",
+ "url": "https://api.github.com/repos/pestphp/pest/zipball/ee2e97e932d158faceeaa63a4dc17324b15152cb",
+ "reference": "ee2e97e932d158faceeaa63a4dc17324b15152cb",
"shasum": ""
},
"require": {
- "ext-mbstring": "*",
- "illuminate/console": "^10.24|^11.0|^12.0|^13.0",
- "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0",
- "illuminate/log": "^10.24|^11.0|^12.0|^13.0",
- "illuminate/process": "^10.24|^11.0|^12.0|^13.0",
- "illuminate/support": "^10.24|^11.0|^12.0|^13.0",
- "nunomaduro/termwind": "^1.15|^2.0",
- "php": "^8.2",
- "symfony/console": "^6.0|^7.0|^8.0"
+ "brianium/paratest": "^7.20.0",
+ "composer/xdebug-handler": "^3.0.5",
+ "nunomaduro/collision": "^8.9.4",
+ "nunomaduro/termwind": "^2.4.0",
+ "pestphp/pest-plugin": "^4.0.0",
+ "pestphp/pest-plugin-arch": "^4.0.2",
+ "pestphp/pest-plugin-mutate": "^4.0.1",
+ "pestphp/pest-plugin-profanity": "^4.2.1",
+ "php": "^8.3.0",
+ "phpunit/phpunit": "^12.5.30",
+ "symfony/process": "^7.4.13|^8.1.0"
+ },
+ "conflict": {
+ "filp/whoops": "<2.18.3",
+ "phpunit/phpunit": ">12.5.30",
+ "sebastian/exporter": "<7.0.0",
+ "webmozart/assert": "<1.11.0"
},
"require-dev": {
- "laravel/framework": "^10.24|^11.0|^12.0|^13.0",
- "laravel/pint": "^1.13",
- "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0",
- "pestphp/pest": "^2.20|^3.0|^4.0",
- "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0",
- "phpstan/phpstan": "^1.12.27",
- "symfony/var-dumper": "^6.3|^7.0|^8.0",
- "symfony/yaml": "^6.3|^7.0|^8.0"
+ "mrpunyapal/peststan": "^0.2.10",
+ "pestphp/pest-dev-tools": "^4.1.0",
+ "pestphp/pest-plugin-browser": "^4.3.1",
+ "pestphp/pest-plugin-type-coverage": "^4.0.4",
+ "psy/psysh": "^0.12.23"
},
+ "bin": [
+ "bin/pest"
+ ],
"type": "library",
"extra": {
- "laravel": {
- "providers": [
- "Laravel\\Pail\\PailServiceProvider"
+ "pest": {
+ "plugins": [
+ "Pest\\Mutate\\Plugins\\Mutate",
+ "Pest\\Plugins\\Configuration",
+ "Pest\\Plugins\\Bail",
+ "Pest\\Plugins\\Cache",
+ "Pest\\Plugins\\Coverage",
+ "Pest\\Plugins\\Init",
+ "Pest\\Plugins\\Environment",
+ "Pest\\Plugins\\Help",
+ "Pest\\Plugins\\Memory",
+ "Pest\\Plugins\\Only",
+ "Pest\\Plugins\\Printer",
+ "Pest\\Plugins\\ProcessIsolation",
+ "Pest\\Plugins\\Profile",
+ "Pest\\Plugins\\Retry",
+ "Pest\\Plugins\\Snapshot",
+ "Pest\\Plugins\\Verbose",
+ "Pest\\Plugins\\Version",
+ "Pest\\Plugins\\Shard",
+ "Pest\\Plugins\\Tia",
+ "Pest\\Plugins\\Parallel"
]
},
- "branch-alias": {
- "dev-main": "1.x-dev"
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
}
},
"autoload": {
+ "files": [
+ "src/Functions.php",
+ "src/Pest.php"
+ ],
"psr-4": {
- "Laravel\\Pail\\": "src/"
+ "Pest\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -8847,336 +9760,381 @@
"MIT"
],
"authors": [
- {
- "name": "Taylor Otwell",
- "email": "taylor@laravel.com"
- },
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
- "description": "Easily delve into your Laravel application's log files directly from the command line.",
- "homepage": "https://github.com/laravel/pail",
+ "description": "The elegant PHP Testing Framework.",
+ "keywords": [
+ "framework",
+ "pest",
+ "php",
+ "test",
+ "testing",
+ "unit"
+ ],
+ "support": {
+ "issues": "https://github.com/pestphp/pest/issues",
+ "source": "https://github.com/pestphp/pest/tree/v4.7.4"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-25T19:09:05+00:00"
+ },
+ {
+ "name": "pestphp/pest-plugin",
+ "version": "v4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/pestphp/pest-plugin.git",
+ "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/9d4b93d7f73d3f9c3189bb22c220fef271cdf568",
+ "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^2.0.0",
+ "composer-runtime-api": "^2.2.2",
+ "php": "^8.3"
+ },
+ "conflict": {
+ "pestphp/pest": "<4.0.0"
+ },
+ "require-dev": {
+ "composer/composer": "^2.8.10",
+ "pestphp/pest": "^4.0.0",
+ "pestphp/pest-dev-tools": "^4.0.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "Pest\\Plugin\\Manager"
+ },
+ "autoload": {
+ "psr-4": {
+ "Pest\\Plugin\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "The Pest plugin manager",
"keywords": [
- "dev",
- "laravel",
- "logs",
+ "framework",
+ "manager",
+ "pest",
"php",
- "tail"
+ "plugin",
+ "test",
+ "testing",
+ "unit"
],
"support": {
- "issues": "https://github.com/laravel/pail/issues",
- "source": "https://github.com/laravel/pail"
+ "source": "https://github.com/pestphp/pest-plugin/tree/v4.0.0"
},
- "time": "2026-05-20T22:24:57+00:00"
+ "funding": [
+ {
+ "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/nunomaduro",
+ "type": "patreon"
+ }
+ ],
+ "time": "2025-08-20T12:35:58+00:00"
},
{
- "name": "laravel/pint",
- "version": "v1.29.1",
+ "name": "pestphp/pest-plugin-arch",
+ "version": "v4.0.2",
"source": {
"type": "git",
- "url": "https://github.com/laravel/pint.git",
- "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
+ "url": "https://github.com/pestphp/pest-plugin-arch.git",
+ "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
- "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c",
+ "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c",
"shasum": ""
},
"require": {
- "ext-json": "*",
- "ext-mbstring": "*",
- "ext-tokenizer": "*",
- "ext-xml": "*",
- "php": "^8.2.0"
+ "pestphp/pest-plugin": "^4.0.0",
+ "php": "^8.3",
+ "ta-tikoma/phpunit-architecture-test": "^0.8.7"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.95.1",
- "illuminate/view": "^12.56.0",
- "larastan/larastan": "^3.9.6",
- "laravel-zero/framework": "^12.1.0",
- "mockery/mockery": "^1.6.12",
- "nunomaduro/termwind": "^2.4.0",
- "pestphp/pest": "^3.8.6",
- "shipfastlabs/agent-detector": "^1.1.3"
+ "pestphp/pest": "^4.4.6",
+ "pestphp/pest-dev-tools": "^4.1.0"
+ },
+ "type": "library",
+ "extra": {
+ "pest": {
+ "plugins": [
+ "Pest\\Arch\\Plugin"
+ ]
+ }
},
- "bin": [
- "builds/pint"
- ],
- "type": "project",
"autoload": {
+ "files": [
+ "src/Autoload.php"
+ ],
"psr-4": {
- "App\\": "app/",
- "Database\\Seeders\\": "database/seeders/",
- "Database\\Factories\\": "database/factories/"
+ "Pest\\Arch\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
- "authors": [
- {
- "name": "Nuno Maduro",
- "email": "enunomaduro@gmail.com"
- }
- ],
- "description": "An opinionated code formatter for PHP.",
- "homepage": "https://laravel.com",
+ "description": "The Arch plugin for Pest PHP.",
"keywords": [
- "dev",
- "format",
- "formatter",
- "lint",
- "linter",
- "php"
+ "arch",
+ "architecture",
+ "framework",
+ "pest",
+ "php",
+ "plugin",
+ "test",
+ "testing",
+ "unit"
],
"support": {
- "issues": "https://github.com/laravel/pint/issues",
- "source": "https://github.com/laravel/pint"
+ "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2"
},
- "time": "2026-04-20T15:26:14+00:00"
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-10T17:20:19+00:00"
},
{
- "name": "mockery/mockery",
- "version": "1.6.12",
+ "name": "pestphp/pest-plugin-laravel",
+ "version": "v4.1.0",
"source": {
"type": "git",
- "url": "https://github.com/mockery/mockery.git",
- "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699"
+ "url": "https://github.com/pestphp/pest-plugin-laravel.git",
+ "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699",
- "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/3057a36669ff11416cc0dc2b521b3aec58c488d0",
+ "reference": "3057a36669ff11416cc0dc2b521b3aec58c488d0",
"shasum": ""
},
"require": {
- "hamcrest/hamcrest-php": "^2.0.1",
- "lib-pcre": ">=7.0",
- "php": ">=7.3"
- },
- "conflict": {
- "phpunit/phpunit": "<8.0"
+ "laravel/framework": "^11.45.2|^12.52.0|^13.0",
+ "pestphp/pest": "^4.4.1",
+ "php": "^8.3.0"
},
"require-dev": {
- "phpunit/phpunit": "^8.5 || ^9.6.17",
- "symplify/easy-coding-standard": "^12.1.14"
+ "laravel/dusk": "^8.3.6",
+ "orchestra/testbench": "^9.13.0|^10.9.0|^11.0",
+ "pestphp/pest-dev-tools": "^4.1.0"
},
"type": "library",
+ "extra": {
+ "pest": {
+ "plugins": [
+ "Pest\\Laravel\\Plugin"
+ ]
+ },
+ "laravel": {
+ "providers": [
+ "Pest\\Laravel\\PestServiceProvider"
+ ]
+ }
+ },
"autoload": {
"files": [
- "library/helpers.php",
- "library/Mockery.php"
+ "src/Autoload.php"
],
"psr-4": {
- "Mockery\\": "library/Mockery"
+ "Pest\\Laravel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Pádraic Brady",
- "email": "padraic.brady@gmail.com",
- "homepage": "https://github.com/padraic",
- "role": "Author"
- },
- {
- "name": "Dave Marshall",
- "email": "dave.marshall@atstsolutions.co.uk",
- "homepage": "https://davedevelopment.co.uk",
- "role": "Developer"
- },
- {
- "name": "Nathanael Esayeas",
- "email": "nathanael.esayeas@protonmail.com",
- "homepage": "https://github.com/ghostwriter",
- "role": "Lead Developer"
- }
+ "MIT"
],
- "description": "Mockery is a simple yet flexible PHP mock object framework",
- "homepage": "https://github.com/mockery/mockery",
+ "description": "The Pest Laravel Plugin",
"keywords": [
- "BDD",
- "TDD",
- "library",
- "mock",
- "mock objects",
- "mockery",
- "stub",
+ "framework",
+ "laravel",
+ "pest",
+ "php",
"test",
- "test double",
- "testing"
+ "testing",
+ "unit"
],
"support": {
- "docs": "https://docs.mockery.io/",
- "issues": "https://github.com/mockery/mockery/issues",
- "rss": "https://github.com/mockery/mockery/releases.atom",
- "security": "https://github.com/mockery/mockery/security/advisories",
- "source": "https://github.com/mockery/mockery"
+ "source": "https://github.com/pestphp/pest-plugin-laravel/tree/v4.1.0"
},
- "time": "2024-05-16T03:13:13+00:00"
+ "funding": [
+ {
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-21T00:29:45+00:00"
},
{
- "name": "myclabs/deep-copy",
- "version": "1.13.4",
+ "name": "pestphp/pest-plugin-mutate",
+ "version": "v4.0.1",
"source": {
"type": "git",
- "url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ "url": "https://github.com/pestphp/pest-plugin-mutate.git",
+ "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
- "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/d9b32b60b2385e1688a68cc227594738ec26d96c",
+ "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c",
"shasum": ""
},
"require": {
- "php": "^7.1 || ^8.0"
- },
- "conflict": {
- "doctrine/collections": "<1.6.8",
- "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ "nikic/php-parser": "^5.6.1",
+ "pestphp/pest-plugin": "^4.0.0",
+ "php": "^8.3",
+ "psr/simple-cache": "^3.0.0"
},
"require-dev": {
- "doctrine/collections": "^1.6.8",
- "doctrine/common": "^2.13.3 || ^3.2.2",
- "phpspec/prophecy": "^1.10",
- "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ "pestphp/pest": "^4.0.0",
+ "pestphp/pest-dev-tools": "^4.0.0",
+ "pestphp/pest-plugin-type-coverage": "^4.0.0"
},
"type": "library",
"autoload": {
- "files": [
- "src/DeepCopy/deep_copy.php"
- ],
"psr-4": {
- "DeepCopy\\": "src/DeepCopy/"
+ "Pest\\Mutate\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
- "description": "Create deep copies (clones) of your objects",
+ "authors": [
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ },
+ {
+ "name": "Sandro Gehri",
+ "email": "sandrogehri@gmail.com"
+ }
+ ],
+ "description": "Mutates your code to find untested cases",
"keywords": [
- "clone",
- "copy",
- "duplicate",
- "object",
- "object graph"
+ "framework",
+ "mutate",
+ "mutation",
+ "pest",
+ "php",
+ "plugin",
+ "test",
+ "testing",
+ "unit"
],
"support": {
- "issues": "https://github.com/myclabs/DeepCopy/issues",
- "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v4.0.1"
},
"funding": [
{
- "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
- "type": "tidelift"
+ "url": "https://www.paypal.com/paypalme/enunomaduro",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/gehrisandro",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
}
],
- "time": "2025-08-01T08:46:24+00:00"
+ "time": "2025-08-21T20:19:25+00:00"
},
{
- "name": "nunomaduro/collision",
- "version": "v8.9.4",
+ "name": "pestphp/pest-plugin-profanity",
+ "version": "v4.2.1",
"source": {
"type": "git",
- "url": "https://github.com/nunomaduro/collision.git",
- "reference": "716af8f95a470e9094cfca09ed897b023be191a5"
+ "url": "https://github.com/pestphp/pest-plugin-profanity.git",
+ "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5",
- "reference": "716af8f95a470e9094cfca09ed897b023be191a5",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27",
+ "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27",
"shasum": ""
},
"require": {
- "filp/whoops": "^2.18.4",
- "nunomaduro/termwind": "^2.4.0",
- "php": "^8.2.0",
- "symfony/console": "^7.4.8 || ^8.0.8"
- },
- "conflict": {
- "laravel/framework": "<11.48.0 || >=14.0.0",
- "phpunit/phpunit": "<11.5.50 || >=14.0.0"
+ "pestphp/pest-plugin": "^4.0.0",
+ "php": "^8.3"
},
"require-dev": {
- "brianium/paratest": "^7.8.5",
- "larastan/larastan": "^3.9.6",
- "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0",
- "laravel/pint": "^1.29.1",
- "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1",
- "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0",
- "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0"
+ "faissaloux/pest-plugin-inside": "^1.9",
+ "pestphp/pest": "^4.0.0",
+ "pestphp/pest-dev-tools": "^4.0.0"
},
"type": "library",
"extra": {
- "laravel": {
- "providers": [
- "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider"
+ "pest": {
+ "plugins": [
+ "Pest\\Profanity\\Plugin"
]
- },
- "branch-alias": {
- "dev-8.x": "8.x-dev"
}
},
"autoload": {
- "files": [
- "./src/Adapters/Phpunit/Autoload.php"
- ],
"psr-4": {
- "NunoMaduro\\Collision\\": "src/"
+ "Pest\\Profanity\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
- "authors": [
- {
- "name": "Nuno Maduro",
- "email": "enunomaduro@gmail.com"
- }
- ],
- "description": "Cli error handling for console/command-line PHP applications.",
+ "description": "The Pest Profanity Plugin",
"keywords": [
- "artisan",
- "cli",
- "command-line",
- "console",
- "dev",
- "error",
- "handling",
- "laravel",
- "laravel-zero",
+ "framework",
+ "pest",
"php",
- "symfony"
+ "plugin",
+ "profanity",
+ "test",
+ "testing",
+ "unit"
],
"support": {
- "issues": "https://github.com/nunomaduro/collision/issues",
- "source": "https://github.com/nunomaduro/collision"
+ "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1"
},
- "funding": [
- {
- "url": "https://www.paypal.com/paypalme/enunomaduro",
- "type": "custom"
- },
- {
- "url": "https://github.com/nunomaduro",
- "type": "github"
- },
- {
- "url": "https://www.patreon.com/nunomaduro",
- "type": "patreon"
- }
- ],
- "time": "2026-04-21T14:04:20+00:00"
+ "time": "2025-12-08T00:13:17+00:00"
},
{
"name": "phar-io/manifest",
@@ -9296,6 +10254,229 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
+ {
+ "name": "phpdocumentor/reflection-common",
+ "version": "2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-2.x": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+ "homepage": "http://www.phpdoc.org",
+ "keywords": [
+ "FQSEN",
+ "phpDocumentor",
+ "phpdoc",
+ "reflection",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+ },
+ "time": "2020-06-27T09:03:43+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-docblock",
+ "version": "6.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582",
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.1",
+ "ext-filter": "*",
+ "php": "^7.4 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.2",
+ "phpdocumentor/type-resolver": "^2.0",
+ "phpstan/phpdoc-parser": "^2.0",
+ "webmozart/assert": "^1.9.1 || ^2"
+ },
+ "require-dev": {
+ "mockery/mockery": "~1.3.5 || ~1.6.0",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-mockery": "^1.1",
+ "phpstan/phpstan-webmozart-assert": "^1.2",
+ "phpunit/phpunit": "^9.5",
+ "psalm/phar": "^5.26",
+ "shipmonk/dead-code-detector": "^0.5.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ },
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3"
+ },
+ "time": "2026-03-18T20:49:53+00:00"
+ },
+ {
+ "name": "phpdocumentor/type-resolver",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/TypeResolver.git",
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.0",
+ "php": "^7.4 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.0",
+ "phpstan/phpdoc-parser": "^2.0"
+ },
+ "require-dev": {
+ "ext-tokenizer": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^9.5",
+ "psalm/phar": "^4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev",
+ "dev-2.x": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ],
+ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
+ },
+ "time": "2026-01-06T21:53:42+00:00"
+ },
+ {
+ "name": "phpstan/phpdoc-parser",
+ "version": "2.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpdoc-parser.git",
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/annotations": "^2.0",
+ "nikic/php-parser": "^5.3.0",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^9.6",
+ "symfony/process": "^5.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\PhpDocParser\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPDoc parser with support for nullable, intersection and generic types",
+ "support": {
+ "issues": "https://github.com/phpstan/phpdoc-parser/issues",
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
+ },
+ "time": "2026-01-25T14:56:51+00:00"
+ },
{
"name": "phpstan/phpstan",
"version": "2.2.1",
@@ -9707,16 +10888,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "12.5.28",
+ "version": "12.5.30",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4"
+ "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4",
- "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/900400a5b616d6fb306f9549f6da33ba615d3fbb",
+ "reference": "900400a5b616d6fb306f9549f6da33ba615d3fbb",
"shasum": ""
},
"require": {
@@ -9730,7 +10911,7 @@
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
- "phpunit/php-code-coverage": "^12.5.6",
+ "phpunit/php-code-coverage": "^12.5.7",
"phpunit/php-file-iterator": "^6.0.1",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
@@ -9740,7 +10921,7 @@
"sebastian/diff": "^7.0.0",
"sebastian/environment": "^8.1.2",
"sebastian/exporter": "^7.0.3",
- "sebastian/global-state": "^8.0.2",
+ "sebastian/global-state": "^8.0.3",
"sebastian/object-enumerator": "^7.0.0",
"sebastian/recursion-context": "^7.0.1",
"sebastian/type": "^6.0.4",
@@ -9785,7 +10966,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.30"
},
"funding": [
{
@@ -9793,7 +10974,7 @@
"type": "other"
}
],
- "time": "2026-05-27T14:01:10+00:00"
+ "time": "2026-06-15T13:12:30+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -10249,26 +11430,26 @@
},
{
"name": "sebastian/global-state",
- "version": "8.0.2",
+ "version": "8.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "ef1377171613d09edd25b7816f05be8313f9115d"
+ "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d",
- "reference": "ef1377171613d09edd25b7816f05be8313f9115d",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9",
+ "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9",
"shasum": ""
},
"require": {
"php": ">=8.3",
"sebastian/object-reflector": "^5.0",
- "sebastian/recursion-context": "^7.0"
+ "sebastian/recursion-context": "^7.0.1"
},
"require-dev": {
"ext-dom": "*",
- "phpunit/phpunit": "^12.0"
+ "phpunit/phpunit": "^12.5.28"
},
"type": "library",
"extra": {
@@ -10299,7 +11480,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/global-state/issues",
"security": "https://github.com/sebastianbergmann/global-state/security/policy",
- "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2"
+ "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3"
},
"funding": [
{
@@ -10319,7 +11500,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-29T11:29:25+00:00"
+ "time": "2026-06-01T15:10:33+00:00"
},
{
"name": "sebastian/lines-of-code",
@@ -10756,6 +11937,65 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
+ {
+ "name": "ta-tikoma/phpunit-architecture-test",
+ "version": "0.8.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git",
+ "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8",
+ "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18.0 || ^5.0.0",
+ "php": "^8.1.0",
+ "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0",
+ "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0",
+ "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.13.7",
+ "phpstan/phpstan": "^1.10.52"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPUnit\\Architecture\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ni Shi",
+ "email": "futik0ma011@gmail.com"
+ },
+ {
+ "name": "Nuno Maduro",
+ "email": "enunomaduro@gmail.com"
+ }
+ ],
+ "description": "Methods for testing application architecture",
+ "keywords": [
+ "architecture",
+ "phpunit",
+ "stucture",
+ "test",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues",
+ "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7"
+ },
+ "time": "2026-02-17T17:25:14+00:00"
+ },
{
"name": "theseer/tokenizer",
"version": "2.0.1",
@@ -10805,6 +12045,72 @@
}
],
"time": "2025-12-08T11:19:18+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "2.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
+ "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-date": "*",
+ "ext-filter": "*",
+ "php": "^8.2"
+ },
+ "suggest": {
+ "ext-intl": "",
+ "ext-simplexml": "",
+ "ext-spl": ""
+ },
+ "type": "library",
+ "extra": {
+ "psalm": {
+ "pluginClass": "Webmozart\\Assert\\PsalmPlugin"
+ },
+ "branch-alias": {
+ "dev-master": "2.0-dev",
+ "dev-feature/2-0": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ },
+ {
+ "name": "Woody Gilk",
+ "email": "woody.gilk@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ],
+ "support": {
+ "issues": "https://github.com/webmozarts/assert/issues",
+ "source": "https://github.com/webmozarts/assert/tree/2.4.1"
+ },
+ "time": "2026-06-15T15:31:57+00:00"
}
],
"aliases": [],
diff --git a/config/app.php b/config/app.php
deleted file mode 100644
index 978839a..0000000
--- a/config/app.php
+++ /dev/null
@@ -1,126 +0,0 @@
- env('APP_NAME', 'Laravel'),
-
- /*
- |--------------------------------------------------------------------------
- | Application Environment
- |--------------------------------------------------------------------------
- |
- | This value determines the "environment" your application is currently
- | running in. This may determine how you prefer to configure various
- | services the application utilizes. Set this in your ".env" file.
- |
- */
-
- 'env' => env('APP_ENV', 'production'),
-
- /*
- |--------------------------------------------------------------------------
- | Application Debug Mode
- |--------------------------------------------------------------------------
- |
- | When your application is in debug mode, detailed error messages with
- | stack traces will be shown on every error that occurs within your
- | application. If disabled, a simple generic error page is shown.
- |
- */
-
- 'debug' => (bool) env('APP_DEBUG', false),
-
- /*
- |--------------------------------------------------------------------------
- | Application URL
- |--------------------------------------------------------------------------
- |
- | This URL is used by the console to properly generate URLs when using
- | the Artisan command line tool. You should set this to the root of
- | the application so that it's available within Artisan commands.
- |
- */
-
- 'url' => env('APP_URL', 'http://localhost'),
-
- /*
- |--------------------------------------------------------------------------
- | Application Timezone
- |--------------------------------------------------------------------------
- |
- | Here you may specify the default timezone for your application, which
- | will be used by the PHP date and date-time functions. The timezone
- | is set to "UTC" by default as it is suitable for most use cases.
- |
- */
-
- 'timezone' => 'UTC',
-
- /*
- |--------------------------------------------------------------------------
- | Application Locale Configuration
- |--------------------------------------------------------------------------
- |
- | The application locale determines the default locale that will be used
- | by Laravel's translation / localization methods. This option can be
- | set to any locale for which you plan to have translation strings.
- |
- */
-
- 'locale' => env('APP_LOCALE', 'en'),
-
- 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
-
- 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
-
- /*
- |--------------------------------------------------------------------------
- | Encryption Key
- |--------------------------------------------------------------------------
- |
- | This key is utilized by Laravel's encryption services and should be set
- | to a random, 32 character string to ensure that all encrypted values
- | are secure. You should do this prior to deploying the application.
- |
- */
-
- 'cipher' => 'AES-256-CBC',
-
- 'key' => env('APP_KEY'),
-
- 'previous_keys' => [
- ...array_filter(
- explode(',', (string) env('APP_PREVIOUS_KEYS', '')),
- ),
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Maintenance Mode Driver
- |--------------------------------------------------------------------------
- |
- | These configuration options determine the driver used to determine and
- | manage Laravel's "maintenance mode" status. The "cache" driver will
- | allow maintenance mode to be controlled across multiple machines.
- |
- | Supported drivers: "file", "cache"
- |
- */
-
- 'maintenance' => [
- 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
- 'store' => env('APP_MAINTENANCE_STORE', 'database'),
- ],
-
-];
diff --git a/config/auth.php b/config/auth.php
deleted file mode 100644
index 76ddf87..0000000
--- a/config/auth.php
+++ /dev/null
@@ -1,117 +0,0 @@
- [
- 'guard' => env('AUTH_GUARD', 'web'),
- 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Authentication Guards
- |--------------------------------------------------------------------------
- |
- | Next, you may define every authentication guard for your application.
- | Of course, a great default configuration has been defined for you
- | which utilizes session storage plus the Eloquent user provider.
- |
- | All authentication guards have a user provider, which defines how the
- | users are actually retrieved out of your database or other storage
- | system used by the application. Typically, Eloquent is utilized.
- |
- | Supported: "session"
- |
- */
-
- 'guards' => [
- 'web' => [
- 'driver' => 'session',
- 'provider' => 'users',
- ],
- ],
-
- /*
- |--------------------------------------------------------------------------
- | User Providers
- |--------------------------------------------------------------------------
- |
- | All authentication guards have a user provider, which defines how the
- | users are actually retrieved out of your database or other storage
- | system used by the application. Typically, Eloquent is utilized.
- |
- | If you have multiple user tables or models you may configure multiple
- | providers to represent the model / table. These providers may then
- | be assigned to any extra authentication guards you have defined.
- |
- | Supported: "database", "eloquent"
- |
- */
-
- 'providers' => [
- 'users' => [
- 'driver' => 'eloquent',
- 'model' => env('AUTH_MODEL', User::class),
- ],
-
- // 'users' => [
- // 'driver' => 'database',
- // 'table' => 'users',
- // ],
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Resetting Passwords
- |--------------------------------------------------------------------------
- |
- | These configuration options specify the behavior of Laravel's password
- | reset functionality, including the table utilized for token storage
- | and the user provider that is invoked to actually retrieve users.
- |
- | The expiry time is the number of minutes that each reset token will be
- | considered valid. This security feature keeps tokens short-lived so
- | they have less time to be guessed. You may change this as needed.
- |
- | The throttle setting is the number of seconds a user must wait before
- | generating more password reset tokens. This prevents the user from
- | quickly generating a very large amount of password reset tokens.
- |
- */
-
- 'passwords' => [
- 'users' => [
- 'provider' => 'users',
- 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
- 'expire' => 60,
- 'throttle' => 60,
- ],
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Password Confirmation Timeout
- |--------------------------------------------------------------------------
- |
- | Here you may define the number of seconds before a password confirmation
- | window expires and users are asked to re-enter their password via the
- | confirmation screen. By default, the timeout lasts for three hours.
- |
- */
-
- 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
-
-];
diff --git a/config/cache.php b/config/cache.php
deleted file mode 100644
index 97a5cdd..0000000
--- a/config/cache.php
+++ /dev/null
@@ -1,130 +0,0 @@
- env('CACHE_STORE', 'database'),
-
- /*
- |--------------------------------------------------------------------------
- | Cache Stores
- |--------------------------------------------------------------------------
- |
- | Here you may define all of the cache "stores" for your application as
- | well as their drivers. You may even define multiple stores for the
- | same cache driver to group types of items stored in your caches.
- |
- | Supported drivers: "array", "database", "file", "memcached",
- | "redis", "dynamodb", "octane",
- | "failover", "null"
- |
- */
-
- 'stores' => [
-
- 'array' => [
- 'driver' => 'array',
- 'serialize' => false,
- ],
-
- 'database' => [
- 'driver' => 'database',
- 'connection' => env('DB_CACHE_CONNECTION'),
- 'table' => env('DB_CACHE_TABLE', 'cache'),
- 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
- 'lock_table' => env('DB_CACHE_LOCK_TABLE'),
- ],
-
- 'file' => [
- 'driver' => 'file',
- 'path' => storage_path('framework/cache/data'),
- 'lock_path' => storage_path('framework/cache/data'),
- ],
-
- 'memcached' => [
- 'driver' => 'memcached',
- 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
- 'sasl' => [
- env('MEMCACHED_USERNAME'),
- env('MEMCACHED_PASSWORD'),
- ],
- 'options' => [
- // Memcached::OPT_CONNECT_TIMEOUT => 2000,
- ],
- 'servers' => [
- [
- 'host' => env('MEMCACHED_HOST', '127.0.0.1'),
- 'port' => env('MEMCACHED_PORT', 11211),
- 'weight' => 100,
- ],
- ],
- ],
-
- 'redis' => [
- 'driver' => 'redis',
- 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
- 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
- ],
-
- 'dynamodb' => [
- 'driver' => 'dynamodb',
- 'key' => env('AWS_ACCESS_KEY_ID'),
- 'secret' => env('AWS_SECRET_ACCESS_KEY'),
- 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
- 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
- 'endpoint' => env('DYNAMODB_ENDPOINT'),
- ],
-
- 'octane' => [
- 'driver' => 'octane',
- ],
-
- 'failover' => [
- 'driver' => 'failover',
- 'stores' => [
- 'database',
- 'array',
- ],
- ],
-
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Cache Key Prefix
- |--------------------------------------------------------------------------
- |
- | When utilizing the APC, database, memcached, Redis, and DynamoDB cache
- | stores, there might be other applications using the same cache. For
- | that reason, you may prefix every cache key to avoid collisions.
- |
- */
-
- 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-'),
-
- /*
- |--------------------------------------------------------------------------
- | Serializable Classes
- |--------------------------------------------------------------------------
- |
- | This value determines the classes that can be unserialized from cache
- | storage. By default, no PHP classes will be unserialized from your
- | cache to prevent gadget chain attacks if your APP_KEY is leaked.
- |
- */
-
- 'serializable_classes' => false,
-
-];
diff --git a/config/database.php b/config/database.php
deleted file mode 100644
index 70e21b4..0000000
--- a/config/database.php
+++ /dev/null
@@ -1,184 +0,0 @@
- env('DB_CONNECTION', 'sqlite'),
-
- /*
- |--------------------------------------------------------------------------
- | Database Connections
- |--------------------------------------------------------------------------
- |
- | Below are all of the database connections defined for your application.
- | An example configuration is provided for each database system which
- | is supported by Laravel. You're free to add / remove connections.
- |
- */
-
- 'connections' => [
-
- 'sqlite' => [
- 'driver' => 'sqlite',
- 'url' => env('DB_URL'),
- 'database' => env('DB_DATABASE', database_path('database.sqlite')),
- 'prefix' => '',
- 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
- 'busy_timeout' => null,
- 'journal_mode' => null,
- 'synchronous' => null,
- 'transaction_mode' => 'DEFERRED',
- ],
-
- 'mysql' => [
- 'driver' => 'mysql',
- 'url' => env('DB_URL'),
- 'host' => env('DB_HOST', '127.0.0.1'),
- 'port' => env('DB_PORT', '3306'),
- 'database' => env('DB_DATABASE', 'laravel'),
- 'username' => env('DB_USERNAME', 'root'),
- 'password' => env('DB_PASSWORD', ''),
- 'unix_socket' => env('DB_SOCKET', ''),
- 'charset' => env('DB_CHARSET', 'utf8mb4'),
- 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
- 'prefix' => '',
- 'prefix_indexes' => true,
- 'strict' => true,
- 'engine' => null,
- 'options' => extension_loaded('pdo_mysql') ? array_filter([
- Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
- ]) : [],
- ],
-
- 'mariadb' => [
- 'driver' => 'mariadb',
- 'url' => env('DB_URL'),
- 'host' => env('DB_HOST', '127.0.0.1'),
- 'port' => env('DB_PORT', '3306'),
- 'database' => env('DB_DATABASE', 'laravel'),
- 'username' => env('DB_USERNAME', 'root'),
- 'password' => env('DB_PASSWORD', ''),
- 'unix_socket' => env('DB_SOCKET', ''),
- 'charset' => env('DB_CHARSET', 'utf8mb4'),
- 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
- 'prefix' => '',
- 'prefix_indexes' => true,
- 'strict' => true,
- 'engine' => null,
- 'options' => extension_loaded('pdo_mysql') ? array_filter([
- Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
- ]) : [],
- ],
-
- 'pgsql' => [
- 'driver' => 'pgsql',
- 'url' => env('DB_URL'),
- 'host' => env('DB_HOST', '127.0.0.1'),
- 'port' => env('DB_PORT', '5432'),
- 'database' => env('DB_DATABASE', 'laravel'),
- 'username' => env('DB_USERNAME', 'root'),
- 'password' => env('DB_PASSWORD', ''),
- 'charset' => env('DB_CHARSET', 'utf8'),
- 'prefix' => '',
- 'prefix_indexes' => true,
- 'search_path' => 'public',
- 'sslmode' => env('DB_SSLMODE', 'prefer'),
- ],
-
- 'sqlsrv' => [
- 'driver' => 'sqlsrv',
- 'url' => env('DB_URL'),
- 'host' => env('DB_HOST', 'localhost'),
- 'port' => env('DB_PORT', '1433'),
- 'database' => env('DB_DATABASE', 'laravel'),
- 'username' => env('DB_USERNAME', 'root'),
- 'password' => env('DB_PASSWORD', ''),
- 'charset' => env('DB_CHARSET', 'utf8'),
- 'prefix' => '',
- 'prefix_indexes' => true,
- // 'encrypt' => env('DB_ENCRYPT', 'yes'),
- // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
- ],
-
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Migration Repository Table
- |--------------------------------------------------------------------------
- |
- | This table keeps track of all the migrations that have already run for
- | your application. Using this information, we can determine which of
- | the migrations on disk haven't actually been run on the database.
- |
- */
-
- 'migrations' => [
- 'table' => 'migrations',
- 'update_date_on_publish' => true,
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Redis Databases
- |--------------------------------------------------------------------------
- |
- | Redis is an open source, fast, and advanced key-value store that also
- | provides a richer body of commands than a typical key-value system
- | such as Memcached. You may define your connection settings here.
- |
- */
-
- 'redis' => [
-
- 'client' => env('REDIS_CLIENT', 'phpredis'),
-
- 'options' => [
- 'cluster' => env('REDIS_CLUSTER', 'redis'),
- 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-database-'),
- 'persistent' => env('REDIS_PERSISTENT', false),
- ],
-
- 'default' => [
- 'url' => env('REDIS_URL'),
- 'host' => env('REDIS_HOST', '127.0.0.1'),
- 'username' => env('REDIS_USERNAME'),
- 'password' => env('REDIS_PASSWORD'),
- 'port' => env('REDIS_PORT', '6379'),
- 'database' => env('REDIS_DB', '0'),
- 'max_retries' => env('REDIS_MAX_RETRIES', 3),
- 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
- 'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
- 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
- ],
-
- 'cache' => [
- 'url' => env('REDIS_URL'),
- 'host' => env('REDIS_HOST', '127.0.0.1'),
- 'username' => env('REDIS_USERNAME'),
- 'password' => env('REDIS_PASSWORD'),
- 'port' => env('REDIS_PORT', '6379'),
- 'database' => env('REDIS_CACHE_DB', '1'),
- 'max_retries' => env('REDIS_MAX_RETRIES', 3),
- 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
- 'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
- 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
- ],
-
- ],
-
-];
diff --git a/config/filesystems.php b/config/filesystems.php
deleted file mode 100644
index 973a0b0..0000000
--- a/config/filesystems.php
+++ /dev/null
@@ -1,80 +0,0 @@
- env('FILESYSTEM_DISK', 'local'),
-
- /*
- |--------------------------------------------------------------------------
- | Filesystem Disks
- |--------------------------------------------------------------------------
- |
- | Below you may configure as many filesystem disks as necessary, and you
- | may even configure multiple disks for the same driver. Examples for
- | most supported storage drivers are configured here for reference.
- |
- | Supported drivers: "local", "ftp", "sftp", "s3"
- |
- */
-
- 'disks' => [
-
- 'local' => [
- 'driver' => 'local',
- 'root' => storage_path('app/private'),
- 'serve' => true,
- 'throw' => false,
- 'report' => false,
- ],
-
- 'public' => [
- 'driver' => 'local',
- 'root' => storage_path('app/public'),
- 'url' => mb_rtrim(env('APP_URL', 'http://localhost'), '/') . '/storage',
- 'visibility' => 'public',
- 'throw' => false,
- 'report' => false,
- ],
-
- 's3' => [
- 'driver' => 's3',
- 'key' => env('AWS_ACCESS_KEY_ID'),
- 'secret' => env('AWS_SECRET_ACCESS_KEY'),
- 'region' => env('AWS_DEFAULT_REGION'),
- 'bucket' => env('AWS_BUCKET'),
- 'url' => env('AWS_URL'),
- 'endpoint' => env('AWS_ENDPOINT'),
- 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
- 'throw' => false,
- 'report' => false,
- ],
-
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Symbolic Links
- |--------------------------------------------------------------------------
- |
- | Here you may configure the symbolic links that will be created when the
- | `storage:link` Artisan command is executed. The array keys should be
- | the locations of the links and the values should be their targets.
- |
- */
-
- 'links' => [
- public_path('storage') => storage_path('app/public'),
- ],
-
-];
diff --git a/config/logging.php b/config/logging.php
deleted file mode 100644
index e4ff8f3..0000000
--- a/config/logging.php
+++ /dev/null
@@ -1,132 +0,0 @@
- env('LOG_CHANNEL', 'stack'),
-
- /*
- |--------------------------------------------------------------------------
- | Deprecations Log Channel
- |--------------------------------------------------------------------------
- |
- | This option controls the log channel that should be used to log warnings
- | regarding deprecated PHP and library features. This allows you to get
- | your application ready for upcoming major versions of dependencies.
- |
- */
-
- 'deprecations' => [
- 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
- 'trace' => env('LOG_DEPRECATIONS_TRACE', false),
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Log Channels
- |--------------------------------------------------------------------------
- |
- | Here you may configure the log channels for your application. Laravel
- | utilizes the Monolog PHP logging library, which includes a variety
- | of powerful log handlers and formatters that you're free to use.
- |
- | Available drivers: "single", "daily", "slack", "syslog",
- | "errorlog", "monolog", "custom", "stack"
- |
- */
-
- 'channels' => [
-
- 'stack' => [
- 'driver' => 'stack',
- 'channels' => explode(',', (string) env('LOG_STACK', 'single')),
- 'ignore_exceptions' => false,
- ],
-
- 'single' => [
- 'driver' => 'single',
- 'path' => storage_path('logs/laravel.log'),
- 'level' => env('LOG_LEVEL', 'debug'),
- 'replace_placeholders' => true,
- ],
-
- 'daily' => [
- 'driver' => 'daily',
- 'path' => storage_path('logs/laravel.log'),
- 'level' => env('LOG_LEVEL', 'debug'),
- 'days' => env('LOG_DAILY_DAYS', 14),
- 'replace_placeholders' => true,
- ],
-
- 'slack' => [
- 'driver' => 'slack',
- 'url' => env('LOG_SLACK_WEBHOOK_URL'),
- 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
- 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
- 'level' => env('LOG_LEVEL', 'critical'),
- 'replace_placeholders' => true,
- ],
-
- 'papertrail' => [
- 'driver' => 'monolog',
- 'level' => env('LOG_LEVEL', 'debug'),
- 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
- 'handler_with' => [
- 'host' => env('PAPERTRAIL_URL'),
- 'port' => env('PAPERTRAIL_PORT'),
- 'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'),
- ],
- 'processors' => [PsrLogMessageProcessor::class],
- ],
-
- 'stderr' => [
- 'driver' => 'monolog',
- 'level' => env('LOG_LEVEL', 'debug'),
- 'handler' => StreamHandler::class,
- 'handler_with' => [
- 'stream' => 'php://stderr',
- ],
- 'formatter' => env('LOG_STDERR_FORMATTER'),
- 'processors' => [PsrLogMessageProcessor::class],
- ],
-
- 'syslog' => [
- 'driver' => 'syslog',
- 'level' => env('LOG_LEVEL', 'debug'),
- 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
- 'replace_placeholders' => true,
- ],
-
- 'errorlog' => [
- 'driver' => 'errorlog',
- 'level' => env('LOG_LEVEL', 'debug'),
- 'replace_placeholders' => true,
- ],
-
- 'null' => [
- 'driver' => 'monolog',
- 'handler' => NullHandler::class,
- ],
-
- 'emergency' => [
- 'path' => storage_path('logs/laravel.log'),
- ],
-
- ],
-
-];
diff --git a/config/mail.php b/config/mail.php
deleted file mode 100644
index 2ae648a..0000000
--- a/config/mail.php
+++ /dev/null
@@ -1,118 +0,0 @@
- env('MAIL_MAILER', 'log'),
-
- /*
- |--------------------------------------------------------------------------
- | Mailer Configurations
- |--------------------------------------------------------------------------
- |
- | Here you may configure all of the mailers used by your application plus
- | their respective settings. Several examples have been configured for
- | you and you are free to add your own as your application requires.
- |
- | Laravel supports a variety of mail "transport" drivers that can be used
- | when delivering an email. You may specify which one you're using for
- | your mailers below. You may also add additional mailers if needed.
- |
- | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
- | "postmark", "resend", "log", "array",
- | "failover", "roundrobin"
- |
- */
-
- 'mailers' => [
-
- 'smtp' => [
- 'transport' => 'smtp',
- 'scheme' => env('MAIL_SCHEME'),
- 'url' => env('MAIL_URL'),
- 'host' => env('MAIL_HOST', '127.0.0.1'),
- 'port' => env('MAIL_PORT', 2525),
- 'username' => env('MAIL_USERNAME'),
- 'password' => env('MAIL_PASSWORD'),
- 'timeout' => null,
- 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
- ],
-
- 'ses' => [
- 'transport' => 'ses',
- ],
-
- 'postmark' => [
- 'transport' => 'postmark',
- // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
- // 'client' => [
- // 'timeout' => 5,
- // ],
- ],
-
- 'resend' => [
- 'transport' => 'resend',
- ],
-
- 'sendmail' => [
- 'transport' => 'sendmail',
- 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
- ],
-
- 'log' => [
- 'transport' => 'log',
- 'channel' => env('MAIL_LOG_CHANNEL'),
- ],
-
- 'array' => [
- 'transport' => 'array',
- ],
-
- 'failover' => [
- 'transport' => 'failover',
- 'mailers' => [
- 'smtp',
- 'log',
- ],
- 'retry_after' => 60,
- ],
-
- 'roundrobin' => [
- 'transport' => 'roundrobin',
- 'mailers' => [
- 'ses',
- 'postmark',
- ],
- 'retry_after' => 60,
- ],
-
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Global "From" Address
- |--------------------------------------------------------------------------
- |
- | You may wish for all emails sent by your application to be sent from
- | the same address. Here you may specify a name and address that is
- | used globally for all emails that are sent by your application.
- |
- */
-
- 'from' => [
- 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
- 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
- ],
-
-];
diff --git a/config/queue.php b/config/queue.php
deleted file mode 100644
index d0773e8..0000000
--- a/config/queue.php
+++ /dev/null
@@ -1,129 +0,0 @@
- env('QUEUE_CONNECTION', 'database'),
-
- /*
- |--------------------------------------------------------------------------
- | Queue Connections
- |--------------------------------------------------------------------------
- |
- | Here you may configure the connection options for every queue backend
- | used by your application. An example configuration is provided for
- | each backend supported by Laravel. You're also free to add more.
- |
- | Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
- | "deferred", "background", "failover", "null"
- |
- */
-
- 'connections' => [
-
- 'sync' => [
- 'driver' => 'sync',
- ],
-
- 'database' => [
- 'driver' => 'database',
- 'connection' => env('DB_QUEUE_CONNECTION'),
- 'table' => env('DB_QUEUE_TABLE', 'jobs'),
- 'queue' => env('DB_QUEUE', 'default'),
- 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
- 'after_commit' => false,
- ],
-
- 'beanstalkd' => [
- 'driver' => 'beanstalkd',
- 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
- 'queue' => env('BEANSTALKD_QUEUE', 'default'),
- 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
- 'block_for' => 0,
- 'after_commit' => false,
- ],
-
- 'sqs' => [
- 'driver' => 'sqs',
- 'key' => env('AWS_ACCESS_KEY_ID'),
- 'secret' => env('AWS_SECRET_ACCESS_KEY'),
- 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
- 'queue' => env('SQS_QUEUE', 'default'),
- 'suffix' => env('SQS_SUFFIX'),
- 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
- 'after_commit' => false,
- ],
-
- 'redis' => [
- 'driver' => 'redis',
- 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
- 'queue' => env('REDIS_QUEUE', 'default'),
- 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
- 'block_for' => null,
- 'after_commit' => false,
- ],
-
- 'deferred' => [
- 'driver' => 'deferred',
- ],
-
- 'background' => [
- 'driver' => 'background',
- ],
-
- 'failover' => [
- 'driver' => 'failover',
- 'connections' => [
- 'database',
- 'deferred',
- ],
- ],
-
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Job Batching
- |--------------------------------------------------------------------------
- |
- | The following options configure the database and table that store job
- | batching information. These options can be updated to any database
- | connection and table which has been defined by your application.
- |
- */
-
- 'batching' => [
- 'database' => env('DB_CONNECTION', 'sqlite'),
- 'table' => 'job_batches',
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Failed Queue Jobs
- |--------------------------------------------------------------------------
- |
- | These options configure the behavior of failed queue job logging so you
- | can control how and where failed jobs are stored. Laravel ships with
- | support for storing failed jobs in a simple file or in a database.
- |
- | Supported drivers: "database-uuids", "dynamodb", "file", "null"
- |
- */
-
- 'failed' => [
- 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
- 'database' => env('DB_CONNECTION', 'sqlite'),
- 'table' => 'failed_jobs',
- ],
-
-];
diff --git a/config/session.php b/config/session.php
deleted file mode 100644
index dfc6a51..0000000
--- a/config/session.php
+++ /dev/null
@@ -1,233 +0,0 @@
- env('SESSION_DRIVER', 'database'),
-
- /*
- |--------------------------------------------------------------------------
- | Session Lifetime
- |--------------------------------------------------------------------------
- |
- | Here you may specify the number of minutes that you wish the session
- | to be allowed to remain idle before it expires. If you want them
- | to expire immediately when the browser is closed then you may
- | indicate that via the expire_on_close configuration option.
- |
- */
-
- 'lifetime' => (int) env('SESSION_LIFETIME', 120),
-
- 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
-
- /*
- |--------------------------------------------------------------------------
- | Session Encryption
- |--------------------------------------------------------------------------
- |
- | This option allows you to easily specify that all of your session data
- | should be encrypted before it's stored. All encryption is performed
- | automatically by Laravel and you may use the session like normal.
- |
- */
-
- 'encrypt' => env('SESSION_ENCRYPT', false),
-
- /*
- |--------------------------------------------------------------------------
- | Session File Location
- |--------------------------------------------------------------------------
- |
- | When utilizing the "file" session driver, the session files are placed
- | on disk. The default storage location is defined here; however, you
- | are free to provide another location where they should be stored.
- |
- */
-
- 'files' => storage_path('framework/sessions'),
-
- /*
- |--------------------------------------------------------------------------
- | Session Database Connection
- |--------------------------------------------------------------------------
- |
- | When using the "database" or "redis" session drivers, you may specify a
- | connection that should be used to manage these sessions. This should
- | correspond to a connection in your database configuration options.
- |
- */
-
- 'connection' => env('SESSION_CONNECTION'),
-
- /*
- |--------------------------------------------------------------------------
- | Session Database Table
- |--------------------------------------------------------------------------
- |
- | When using the "database" session driver, you may specify the table to
- | be used to store sessions. Of course, a sensible default is defined
- | for you; however, you're welcome to change this to another table.
- |
- */
-
- 'table' => env('SESSION_TABLE', 'sessions'),
-
- /*
- |--------------------------------------------------------------------------
- | Session Cache Store
- |--------------------------------------------------------------------------
- |
- | When using one of the framework's cache driven session backends, you may
- | define the cache store which should be used to store the session data
- | between requests. This must match one of your defined cache stores.
- |
- | Affects: "dynamodb", "memcached", "redis"
- |
- */
-
- 'store' => env('SESSION_STORE'),
-
- /*
- |--------------------------------------------------------------------------
- | Session Sweeping Lottery
- |--------------------------------------------------------------------------
- |
- | Some session drivers must manually sweep their storage location to get
- | rid of old sessions from storage. Here are the chances that it will
- | happen on a given request. By default, the odds are 2 out of 100.
- |
- */
-
- 'lottery' => [2, 100],
-
- /*
- |--------------------------------------------------------------------------
- | Session Cookie Name
- |--------------------------------------------------------------------------
- |
- | Here you may change the name of the session cookie that is created by
- | the framework. Typically, you should not need to change this value
- | since doing so does not grant a meaningful security improvement.
- |
- */
-
- 'cookie' => env(
- 'SESSION_COOKIE',
- Str::slug((string) env('APP_NAME', 'laravel')) . '-session',
- ),
-
- /*
- |--------------------------------------------------------------------------
- | Session Cookie Path
- |--------------------------------------------------------------------------
- |
- | The session cookie path determines the path for which the cookie will
- | be regarded as available. Typically, this will be the root path of
- | your application, but you're free to change this when necessary.
- |
- */
-
- 'path' => env('SESSION_PATH', '/'),
-
- /*
- |--------------------------------------------------------------------------
- | Session Cookie Domain
- |--------------------------------------------------------------------------
- |
- | This value determines the domain and subdomains the session cookie is
- | available to. By default, the cookie will be available to the root
- | domain without subdomains. Typically, this shouldn't be changed.
- |
- */
-
- 'domain' => env('SESSION_DOMAIN'),
-
- /*
- |--------------------------------------------------------------------------
- | HTTPS Only Cookies
- |--------------------------------------------------------------------------
- |
- | By setting this option to true, session cookies will only be sent back
- | to the server if the browser has a HTTPS connection. This will keep
- | the cookie from being sent to you when it can't be done securely.
- |
- */
-
- 'secure' => env('SESSION_SECURE_COOKIE'),
-
- /*
- |--------------------------------------------------------------------------
- | HTTP Access Only
- |--------------------------------------------------------------------------
- |
- | Setting this value to true will prevent JavaScript from accessing the
- | value of the cookie and the cookie will only be accessible through
- | the HTTP protocol. It's unlikely you should disable this option.
- |
- */
-
- 'http_only' => env('SESSION_HTTP_ONLY', true),
-
- /*
- |--------------------------------------------------------------------------
- | Same-Site Cookies
- |--------------------------------------------------------------------------
- |
- | This option determines how your cookies behave when cross-site requests
- | take place, and can be used to mitigate CSRF attacks. By default, we
- | will set this value to "lax" to permit secure cross-site requests.
- |
- | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
- |
- | Supported: "lax", "strict", "none", null
- |
- */
-
- 'same_site' => env('SESSION_SAME_SITE', 'lax'),
-
- /*
- |--------------------------------------------------------------------------
- | Partitioned Cookies
- |--------------------------------------------------------------------------
- |
- | Setting this value to true will tie the cookie to the top-level site for
- | a cross-site context. Partitioned cookies are accepted by the browser
- | when flagged "secure" and the Same-Site attribute is set to "none".
- |
- */
-
- 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
-
- /*
- |--------------------------------------------------------------------------
- | Session Serialization
- |--------------------------------------------------------------------------
- |
- | This value controls the serialization strategy for session data, which
- | is JSON by default. Setting this to "php" allows the storage of PHP
- | objects in the session but can make an application vulnerable to
- | "gadget chain" serialization attacks if the APP_KEY is leaked.
- |
- | Supported: "json", "php"
- |
- */
-
- 'serialization' => 'json',
-
-];
diff --git a/package-lock.json b/package-lock.json
index 6bb25ac..a2fbccb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4,9 +4,6 @@
"requires": true,
"packages": {
"": {
- "dependencies": {
- "jquery": "^3.7.1"
- },
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.2.3",
@@ -3373,12 +3370,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
- "node_modules/jquery": {
- "version": "3.7.1",
- "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
- "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
- "license": "MIT"
- },
"node_modules/js-yaml": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.0.tgz",
diff --git a/package.json b/package.json
index 7f417aa..76140f7 100644
--- a/package.json
+++ b/package.json
@@ -6,9 +6,6 @@
"build": "vite build",
"dev": "vite"
},
- "dependencies": {
- "jquery": "^3.7.1"
- },
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"concurrently": "^9.2.3",
diff --git a/resources/css/app.css b/resources/css/app.css
index 8bb62d0..79786df 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -8,6 +8,9 @@
padding-inline: 1rem;
}
+/* Alpine: hide elements until Alpine initialises */
+[x-cloak] { display: none !important; }
+
/* https://stackoverflow.com/questions/71074/how-to-remove-firefoxs-dotted-outline-on-buttons-as-well-as-links */
:focus { outline: none; }
::-moz-focus-inner { border: 0; }
diff --git a/resources/js/app.js b/resources/js/app.js
index 9b64b40..b1de7f0 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,44 +1,3 @@
-import jquery from 'jquery';
-
-window.$ = window.jQuery = jquery;
-
-$(function () {
- $('body')
- .on('click', '.upvote', function (e) {
- e.preventDefault();
- vote($(this), 1);
- })
- .on('click', '.downvote', function (e) {
- e.preventDefault();
- vote($(this), -1);
- });
-});
-
-function vote($voteLink, value) {
- if (typeof userId === 'undefined') {
- alert('You must log in to vote. To log in, click the "log in" link in the top-right corner.');
- return;
- }
-
- var emailId = $voteLink.data('email-id');
- if ($voteLink.hasClass('active')) {
- value = 0;
- }
-
- $.post('/votes/' + emailId, {
- userId: userId,
- value: value,
- })
- .done(function (data) {
- $voteLink.parent().find('.vote-action').removeClass('active');
- if (data.newValue === 1) {
- $voteLink.parent().find('.upvote').addClass('active');
- } else if (data.newValue === -1) {
- $voteLink.parent().find('.downvote').addClass('active');
- }
- $voteLink.parent().find('.vote-value').text(data.newTotal);
- })
- .fail(function () {
- alert('An error occurred');
- });
-}
+// Livewire (v4) automatically injects its runtime and Alpine.
+// Interactivity that used to live here (voting, email collapse, "next unread")
+// is now handled by Livewire actions and Alpine directives in the components.
diff --git a/resources/views/auth/login-error.blade.php b/resources/views/auth/login-error.blade.php
index 928387e..42d0a2e 100644
--- a/resources/views/auth/login-error.blade.php
+++ b/resources/views/auth/login-error.blade.php
@@ -1,9 +1,5 @@
-@extends('layout')
-
-@section('content')
-
+
It seems there was an error while authenticating:
{{ $error }}
-
-@endsection
+
diff --git a/resources/views/layout.blade.php b/resources/views/components/layouts/app.blade.php
similarity index 85%
rename from resources/views/layout.blade.php
rename to resources/views/components/layouts/app.blade.php
index 1d92f06..c66110d 100644
--- a/resources/views/layout.blade.php
+++ b/resources/views/components/layouts/app.blade.php
@@ -1,3 +1,4 @@
+@php($defaultTitle = "#externals - Opening PHP's #internals to the outside")
@@ -6,7 +7,7 @@
- @yield('pageTitle', "#externals - Opening PHP's #internals to the outside")
+ {{ $title ?? $defaultTitle }}
@@ -19,7 +20,7 @@
-
+
@@ -28,15 +29,7 @@
- @isset($user)
- @if($user)
-
- @endif
- @endisset
-
- @if($noIndex)
+ @if(config('externals.no_index'))
@endif
@@ -52,10 +45,10 @@
Top
- @if(!empty($user))
-
- {{ $user->name }}
-
- @yield('content')
+ {{ $slot }}
-
- @yield('scripts')
diff --git a/resources/views/threads/thread-item.blade.php b/resources/views/components/thread-item.blade.php
similarity index 87%
rename from resources/views/threads/thread-item.blade.php
rename to resources/views/components/thread-item.blade.php
index 97bd8cc..feb1c2f 100644
--- a/resources/views/threads/thread-item.blade.php
+++ b/resources/views/components/thread-item.blade.php
@@ -1,10 +1,13 @@
+@props(['item'])
+
@php($email = $item->email)
-
+
@@ -24,13 +27,13 @@ class="mb-6 overflow-hidden {{ $email->isRead ? '' : 'unread' }}">
@endif
-
+
{!! $email->content !!}
@foreach($item->replies as $reply)
- @include('threads.thread-item', ['item' => $reply])
+
@endforeach
diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php
index 7406f98..45fffb0 100644
--- a/resources/views/errors/404.blade.php
+++ b/resources/views/errors/404.blade.php
@@ -1,11 +1,7 @@
-@extends('layout')
-
-@section('content')
-
+
😱
-
-@endsection
+
diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php
deleted file mode 100644
index 292ad06..0000000
--- a/resources/views/home.blade.php
+++ /dev/null
@@ -1,134 +0,0 @@
-@extends('layout')
-
-@section('content')
-
-
- Opening PHP's #internals mailing list to the outside.
-
- Your browser either doesn't support javascript or it is disabled.
- This website does not work without javascript.
- Make sure to activate it or consider switching to a modern browser.
-
-
-
-
- {{-- Search --}}
-
-
-
-
-
- @verbatim
-
-
- @endverbatim
-
-
-
-
- @include('threads.thread-list')
-
-
-
- @if($page > 1)
-
- 1
-
- @endif
- @if($page > 5)
-
- ...
-
- @endif
- @if($page > 2)
- @for($i = max($page - 3, 2); $i <= $page - 1; $i++)
-
- {{ $i }}
-
- @endfor
- @endif
-
- {{ $page }}
-
- @if($page < $pageCount)
- @for($i = $page + 1; $i <= min($page + 4, $pageCount); $i++)
-
- {{ $i }}
-
- @endfor
- @endif
- @if($page + 4 < $pageCount)
-
- ...
-
- @endif
-
-
-
-
-@endsection
-
-@section('scripts')
- @parent
-
-
-
-
-
-@endsection
diff --git a/resources/views/livewire/pages/home.blade.php b/resources/views/livewire/pages/home.blade.php
new file mode 100644
index 0000000..95bcd73
--- /dev/null
+++ b/resources/views/livewire/pages/home.blade.php
@@ -0,0 +1,96 @@
+
+
+
+
+ Opening PHP's #internals mailing list to the outside.
+
+ Your browser either doesn't support javascript or it is disabled.
+ This website does not work without javascript.
+ Make sure to activate it or consider switching to a modern browser.
+
+
+
+
+ {{-- Search --}}
+
+
+
+
+
+ @verbatim
+
+
+ @endverbatim
+
+
+
+ @assets
+
+ @endassets
+
+ @script
+
+ @endscript
+
diff --git a/resources/views/news.blade.php b/resources/views/livewire/pages/news.blade.php
similarity index 96%
rename from resources/views/news.blade.php
rename to resources/views/livewire/pages/news.blade.php
index 8e30b23..d4060b7 100644
--- a/resources/views/news.blade.php
+++ b/resources/views/livewire/pages/news.blade.php
@@ -1,7 +1,13 @@
-@extends('layout')
+
+
+
-
-@endsection
+
diff --git a/resources/views/livewire/pages/stats.blade.php b/resources/views/livewire/pages/stats.blade.php
new file mode 100644
index 0000000..8ef7b52
--- /dev/null
+++ b/resources/views/livewire/pages/stats.blade.php
@@ -0,0 +1,35 @@
+
+ */
+ public function with(): array
+ {
+ $ttl = now()->addMinutes(5);
+
+ return [
+ 'userCount' => Cache::remember('stats.userCount', $ttl, fn() => User::count()),
+ 'threadCount' => Cache::remember('stats.threadCount', $ttl, fn() => Email::where('isThreadRoot', true)->count()),
+ 'emailCount' => Cache::remember('stats.emailCount', $ttl, fn() => Email::count()),
+ ];
+ }
+}; ?>
+
+
+
Stats!
+
+
There are:
+
+
+ {{ $userCount }} users
+ {{ $threadCount }} threads
+ {{ $emailCount }} emails
+
+
diff --git a/resources/views/livewire/pages/thread.blade.php b/resources/views/livewire/pages/thread.blade.php
new file mode 100644
index 0000000..2153643
--- /dev/null
+++ b/resources/views/livewire/pages/thread.blade.php
@@ -0,0 +1,93 @@
+first();
+ if (! $email) {
+ throw new NotFoundException("Email $number was not found");
+ }
+
+ if (! $email->isThreadRoot()) {
+ $root = Email::find($email->threadId);
+ if ($root) {
+ return $this->redirect("/message/{$root->number}#$number");
+ }
+ // Root message not found — render the thread from this URL anyway
+ }
+
+ $user = auth()->user();
+ // Build the thread view BEFORE marking the thread as read
+ $this->thread = $threads->getThreadView($email, $user);
+ $this->number = $number;
+ $this->subject = $email->subject;
+
+ if ($user) {
+ app(MarkEmailAsRead::class)->handle($email, $user);
+ }
+
+ return null;
+ }
+
+ public function rendering(View $view): void
+ {
+ $view->title($this->subject . ' - Externals');
+ }
+
+ /**
+ * @return array
+ */
+ public function with(): array
+ {
+ return ['thread' => $this->thread];
+ }
+}; ?>
+
+
+
+
+
+
+
+ {{ $subject }}
+
+
+ @auth
+
+ (n)ext unread email
+
+ @endauth
+
+ @foreach($thread as $item)
+
+ @endforeach
+
diff --git a/resources/views/livewire/pages/top.blade.php b/resources/views/livewire/pages/top.blade.php
new file mode 100644
index 0000000..0747451
--- /dev/null
+++ b/resources/views/livewire/pages/top.blade.php
@@ -0,0 +1,14 @@
+
+
+
+
Most interesting threads of the last month
+
+
+
diff --git a/resources/views/livewire/thread-list.blade.php b/resources/views/livewire/thread-list.blade.php
new file mode 100644
index 0000000..8f4c3ad
--- /dev/null
+++ b/resources/views/livewire/thread-list.blade.php
@@ -0,0 +1,136 @@
+check()) {
+ $this->js(<<<'JS'
+ alert('You must log in to vote. To log in, click the "log in" link in the top-right corner.');
+ JS);
+
+ return;
+ }
+
+ $userId = (int) auth()->id();
+
+ // Clicking the arrow that is already active removes the vote.
+ $current = (int) (Vote::query()
+ ->where('userId', $userId)
+ ->where('emailNumber', $number)
+ ->value('value') ?? 0);
+ $newValue = $current === $value ? 0 : $value;
+
+ app(CastVote::class)->handle($userId, $number, $newValue);
+ }
+
+ /**
+ * @return array
+ */
+ public function with(): array
+ {
+ $user = auth()->user();
+
+ if ($this->mode === 'top') {
+ return [
+ 'threads' => app(ThreadQuery::class)->findTopThreads(1, $user),
+ 'pageCount' => 1,
+ ];
+ }
+
+ $threadCount = Cache::remember(
+ 'stats.threadCount',
+ now()->addMinutes(5),
+ fn() => Email::where('isThreadRoot', true)->count(),
+ );
+
+ return [
+ 'threads' => app(ThreadQuery::class)->findLatestThreads($this->page, $user),
+ 'pageCount' => (int) ceil($threadCount / 20),
+ ];
+ }
+}; ?>
+
+
+ @foreach($threads as $thread)
+
+ @endforeach
+
+ @if($mode === 'latest')
+
+ @if($page > 1)
+
+ 1
+
+ @endif
+ @if($page > 5)
+
…
+ @endif
+ @if($page > 2)
+ @for($i = max($page - 3, 2); $i <= $page - 1; $i++)
+
+ {{ $i }}
+
+ @endfor
+ @endif
+
+ {{ $page }}
+
+ @if($page < $pageCount)
+ @for($i = $page + 1; $i <= min($page + 4, $pageCount); $i++)
+
+ {{ $i }}
+
+ @endfor
+ @endif
+ @if($page + 4 < $pageCount)
+
…
+ @endif
+
+ @endif
+
diff --git a/resources/views/stats.blade.php b/resources/views/stats.blade.php
deleted file mode 100644
index 16acaff..0000000
--- a/resources/views/stats.blade.php
+++ /dev/null
@@ -1,15 +0,0 @@
-@extends('layout')
-
-@section('content')
-
- Stats!
-
- There are:
-
-
- {{ $userCount }} users
- {{ $threadCount }} threads
- {{ $emailCount }} emails
-
-
-@endsection
diff --git a/resources/views/thread.blade.php b/resources/views/thread.blade.php
deleted file mode 100644
index ed905f8..0000000
--- a/resources/views/thread.blade.php
+++ /dev/null
@@ -1,71 +0,0 @@
-@extends('layout')
-
-@section('pageTitle', $subject . ' - Externals')
-
-@section('content')
-
-
-
-
-
- {{ $subject }}
-
-
- @if(!empty($user))
-
- (n)ext unread email
-
- @endif
-
- @foreach($thread as $item)
- @include('threads.thread-item', ['item' => $item])
- @endforeach
-
-@endsection
-
-@section('scripts')
- @parent
-
-
-@endsection
diff --git a/resources/views/threads/thread-list.blade.php b/resources/views/threads/thread-list.blade.php
deleted file mode 100644
index d16114b..0000000
--- a/resources/views/threads/thread-list.blade.php
+++ /dev/null
@@ -1,37 +0,0 @@
-@foreach($threads as $thread)
-
-
-
-
- {{ $thread['subject'] }}
-
-
- {{-- Date --}}
- {{ \Carbon\Carbon::parse($thread['date'])->diffForHumans() }}
- {{-- Author --}}
- @if(!empty($thread['fromName']))
- by {{ $thread['fromName'] }}
- @endif
-
-
- {{-- Number of responses --}}
- @if($thread['emailCount'] > 1)
-
-
- {{ $thread['emailCount'] }}
-
- @endif
-
-@endforeach
diff --git a/resources/views/top.blade.php b/resources/views/top.blade.php
deleted file mode 100644
index 6d887f8..0000000
--- a/resources/views/top.blade.php
+++ /dev/null
@@ -1,9 +0,0 @@
-@extends('layout')
-
-@section('content')
-
- Most interesting threads of the last month
-
- @include('threads.thread-list')
-
-@endsection
diff --git a/routes/web.php b/routes/web.php
index 405bf6e..2da1128 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,31 +2,25 @@
declare(strict_types=1);
+use App\Http\Controllers\Auth\GithubController;
use App\Http\Controllers\EmailSourceController;
-use App\Http\Controllers\HomeController;
use App\Http\Controllers\LegacyThreadRedirectController;
-use App\Http\Controllers\LoginController;
use App\Http\Controllers\LogoutController;
-use App\Http\Controllers\NewsController;
use App\Http\Controllers\RssController;
use App\Http\Controllers\RssRfcController;
-use App\Http\Controllers\StatsController;
-use App\Http\Controllers\ThreadController;
-use App\Http\Controllers\TopController;
-use App\Http\Controllers\VoteController;
use Illuminate\Support\Facades\Route;
+use Livewire\Volt\Volt;
-Route::get('/', HomeController::class);
-Route::get('/top', TopController::class);
-Route::get('/news', NewsController::class);
-Route::get('/stats', StatsController::class);
+Volt::route('/', 'pages.home');
+Volt::route('/top', 'pages.top');
+Volt::route('/news', 'pages.news');
+Volt::route('/stats', 'pages.stats');
-Route::get('/login', LoginController::class)->name('login');
-Route::post('/logout', LogoutController::class);
+Route::get('/login', GithubController::class)->name('login');
+Route::post('/logout', LogoutController::class)->name('logout');
-Route::get('/message/{number}', ThreadController::class)->whereNumber('number');
+Volt::route('/message/{number}', 'pages.thread')->whereNumber('number');
Route::get('/email/{number}/source', EmailSourceController::class)->whereNumber('number')->middleware('auth');
-Route::post('/votes/{number}', VoteController::class)->whereNumber('number');
Route::get('/rss', RssController::class);
Route::get('/rss-rfc', RssRfcController::class);
diff --git a/tests/Feature/Actions/CastVoteTest.php b/tests/Feature/Actions/CastVoteTest.php
index a155cb9..6b54ac3 100644
--- a/tests/Feature/Actions/CastVoteTest.php
+++ b/tests/Feature/Actions/CastVoteTest.php
@@ -2,64 +2,54 @@
declare(strict_types=1);
-namespace Feature\Actions;
-
use App\Actions\CastVote;
use App\Models\Email;
use App\Models\Thread;
use App\Models\User;
use App\Models\Vote;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-class CastVoteTest extends TestCase
-{
- use RefreshDatabase;
+uses(RefreshDatabase::class);
- public function test_records_a_new_vote_and_returns_total(): void
- {
- Email::factory()->create(['number' => 1]);
- $user = User::factory()->create();
+test('records a new vote and returns total', function (): void {
+ Email::factory()->create(['number' => 1]);
+ $user = User::factory()->create();
- $total = app(CastVote::class)->handle($user->id, 1, 1);
+ $total = app(CastVote::class)->handle($user->id, 1, 1);
- $this->assertSame(1, $total);
- $this->assertDatabaseHas('votes', ['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]);
- }
+ $this->assertSame(1, $total);
+ $this->assertDatabaseHas('votes', ['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]);
+});
- public function test_updates_existing_vote_for_same_user(): void
- {
- Email::factory()->create(['number' => 1]);
- $user = User::factory()->create();
+test('updates existing vote for same user', function (): void {
+ Email::factory()->create(['number' => 1]);
+ $user = User::factory()->create();
- app(CastVote::class)->handle($user->id, 1, 1);
- $total = app(CastVote::class)->handle($user->id, 1, -1);
+ app(CastVote::class)->handle($user->id, 1, 1);
+ $total = app(CastVote::class)->handle($user->id, 1, -1);
- $this->assertSame(-1, $total);
- $this->assertSame(1, Vote::where('userId', $user->id)->where('emailNumber', 1)->count());
- }
+ $this->assertSame(-1, $total);
+ $this->assertSame(1, Vote::where('userId', $user->id)->where('emailNumber', 1)->count());
+});
- public function test_sums_votes_from_multiple_users(): void
- {
- Email::factory()->create(['number' => 1]);
- $alice = User::factory()->create();
- $bob = User::factory()->create();
+test('sums votes from multiple users', function (): void {
+ Email::factory()->create(['number' => 1]);
+ $alice = User::factory()->create();
+ $bob = User::factory()->create();
- app(CastVote::class)->handle($alice->id, 1, 1);
- $total = app(CastVote::class)->handle($bob->id, 1, 1);
+ app(CastVote::class)->handle($alice->id, 1, 1);
+ $total = app(CastVote::class)->handle($bob->id, 1, 1);
- $this->assertSame(2, $total);
- }
+ $this->assertSame(2, $total);
+});
- public function test_refreshes_thread_row(): void
- {
- Email::factory()->create(['id' => '', 'number' => 1]);
- $user = User::factory()->create();
+test('refreshes thread row', function (): void {
+ Email::factory()->create(['id' => '', 'number' => 1]);
+ $user = User::factory()->create();
- app(CastVote::class)->handle($user->id, 1, 1);
+ app(CastVote::class)->handle($user->id, 1, 1);
- $thread = Thread::find('');
- $this->assertNotNull($thread);
- $this->assertSame(1, $thread->votes);
- }
-}
+ $thread = Thread::find('');
+ $this->assertNotNull($thread);
+ $this->assertSame(1, $thread->votes);
+});
diff --git a/tests/Feature/Actions/GetOrCreateUserTest.php b/tests/Feature/Actions/GetOrCreateUserTest.php
index 5f470e1..755c562 100644
--- a/tests/Feature/Actions/GetOrCreateUserTest.php
+++ b/tests/Feature/Actions/GetOrCreateUserTest.php
@@ -2,43 +2,34 @@
declare(strict_types=1);
-namespace Feature\Actions;
-
use App\Actions\GetOrCreateUser;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-class GetOrCreateUserTest extends TestCase
-{
- use RefreshDatabase;
+uses(RefreshDatabase::class);
- public function test_should_create_new_user(): void
- {
- $user = app(GetOrCreateUser::class)->handle('abc', 'joe');
+test('should create new user', function (): void {
+ $user = app(GetOrCreateUser::class)->handle('abc', 'joe');
- $this->assertSame('abc', $user->githubId);
- $this->assertSame('joe', $user->name);
- $this->assertDatabaseHas('users', ['githubId' => 'abc', 'name' => 'joe']);
- }
+ $this->assertSame('abc', $user->githubId);
+ $this->assertSame('joe', $user->name);
+ $this->assertDatabaseHas('users', ['githubId' => 'abc', 'name' => 'joe']);
+});
- public function test_should_return_existing_user(): void
- {
- $existing = User::factory()->create(['githubId' => 'abc', 'name' => 'joe']);
+test('should return existing user', function (): void {
+ $existing = User::factory()->create(['githubId' => 'abc', 'name' => 'joe']);
- $user = app(GetOrCreateUser::class)->handle('abc', 'joe');
+ $user = app(GetOrCreateUser::class)->handle('abc', 'joe');
- $this->assertSame($existing->id, $user->id);
- $this->assertSame('joe', $user->name);
- }
+ $this->assertSame($existing->id, $user->id);
+ $this->assertSame('joe', $user->name);
+});
- public function test_should_update_user_name_when_changed(): void
- {
- User::factory()->create(['githubId' => 'abc', 'name' => 'joe']);
+test('should update user name when changed', function (): void {
+ User::factory()->create(['githubId' => 'abc', 'name' => 'joe']);
- $user = app(GetOrCreateUser::class)->handle('abc', 'jane');
+ $user = app(GetOrCreateUser::class)->handle('abc', 'jane');
- $this->assertSame('jane', $user->name);
- $this->assertDatabaseHas('users', ['githubId' => 'abc', 'name' => 'jane']);
- }
-}
+ $this->assertSame('jane', $user->name);
+ $this->assertDatabaseHas('users', ['githubId' => 'abc', 'name' => 'jane']);
+});
diff --git a/tests/Feature/Actions/MarkEmailAsReadTest.php b/tests/Feature/Actions/MarkEmailAsReadTest.php
index e501e54..859c354 100644
--- a/tests/Feature/Actions/MarkEmailAsReadTest.php
+++ b/tests/Feature/Actions/MarkEmailAsReadTest.php
@@ -2,46 +2,38 @@
declare(strict_types=1);
-namespace Feature\Actions;
-
use App\Actions\MarkEmailAsRead;
use App\Models\Email;
use App\Models\User;
use App\Models\UserEmailRead;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
-use Tests\TestCase;
-class MarkEmailAsReadTest extends TestCase
-{
- use RefreshDatabase;
+uses(RefreshDatabase::class);
- public function test_creates_read_record(): void
- {
- Carbon::setTestNow('2026-05-14 10:00:00');
- $email = Email::factory()->create();
- $user = User::factory()->create();
+test('creates read record', function (): void {
+ Carbon::setTestNow('2026-05-14 10:00:00');
+ $email = Email::factory()->create();
+ $user = User::factory()->create();
- app(MarkEmailAsRead::class)->handle($email, $user);
+ app(MarkEmailAsRead::class)->handle($email, $user);
- $row = UserEmailRead::where('userId', $user->id)->where('emailId', $email->id)->first();
- $this->assertNotNull($row);
- $this->assertSame('2026-05-14 10:00:00', $row->lastReadDate->format('Y-m-d H:i:s'));
- }
+ $row = UserEmailRead::where('userId', $user->id)->where('emailId', $email->id)->first();
+ $this->assertNotNull($row);
+ $this->assertSame('2026-05-14 10:00:00', $row->lastReadDate->format('Y-m-d H:i:s'));
+});
- public function test_updates_existing_read_record(): void
- {
- $email = Email::factory()->create();
- $user = User::factory()->create();
+test('updates existing read record', function (): void {
+ $email = Email::factory()->create();
+ $user = User::factory()->create();
- Carbon::setTestNow('2026-01-01 00:00:00');
- app(MarkEmailAsRead::class)->handle($email, $user);
+ Carbon::setTestNow('2026-01-01 00:00:00');
+ app(MarkEmailAsRead::class)->handle($email, $user);
- Carbon::setTestNow('2026-05-14 10:00:00');
- app(MarkEmailAsRead::class)->handle($email, $user);
+ Carbon::setTestNow('2026-05-14 10:00:00');
+ app(MarkEmailAsRead::class)->handle($email, $user);
- $rows = UserEmailRead::where('userId', $user->id)->where('emailId', $email->id)->get();
- $this->assertCount(1, $rows);
- $this->assertSame('2026-05-14 10:00:00', $rows->first()->lastReadDate->format('Y-m-d H:i:s'));
- }
-}
+ $rows = UserEmailRead::where('userId', $user->id)->where('emailId', $email->id)->get();
+ $this->assertCount(1, $rows);
+ $this->assertSame('2026-05-14 10:00:00', $rows->first()->lastReadDate->format('Y-m-d H:i:s'));
+});
diff --git a/tests/Feature/Actions/RefreshAllThreadsTest.php b/tests/Feature/Actions/RefreshAllThreadsTest.php
index f288316..200eddd 100644
--- a/tests/Feature/Actions/RefreshAllThreadsTest.php
+++ b/tests/Feature/Actions/RefreshAllThreadsTest.php
@@ -2,59 +2,50 @@
declare(strict_types=1);
-namespace Feature\Actions;
-
use App\Actions\RefreshAllThreads;
use App\Models\Email;
use App\Models\Thread;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-
-class RefreshAllThreadsTest extends TestCase
-{
- use RefreshDatabase;
-
- public function test_creates_a_thread_row_for_every_root(): void
- {
- $rootA = Email::factory()->create(['id' => '']);
- Email::factory()->replyTo($rootA)->create();
- Email::factory()->create(['id' => '']);
-
- app(RefreshAllThreads::class)->handle();
-
- $threadA = Thread::find('');
- $threadB = Thread::find('');
- $this->assertNotNull($threadA);
- $this->assertNotNull($threadB);
- $this->assertSame(2, $threadA->emailCount);
- $this->assertSame(1, $threadB->emailCount);
- }
-
- public function test_ignores_non_root_emails(): void
- {
- $root = Email::factory()->create();
- Email::factory()->replyTo($root)->create();
-
- app(RefreshAllThreads::class)->handle();
-
- $this->assertSame(1, Thread::count());
- }
-
- public function test_replaces_existing_thread_rows(): void
- {
- Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
- Thread::factory()->create([
- 'emailId' => '',
- 'emailNumber' => 1,
- 'lastUpdate' => '2020-01-01 00:00:00',
- 'emailCount' => 999,
- 'votes' => 999,
- ]);
-
- app(RefreshAllThreads::class)->handle();
-
- $thread = Thread::find('');
- $this->assertSame(1, $thread->emailCount);
- $this->assertSame(0, $thread->votes);
- }
-}
+
+uses(RefreshDatabase::class);
+
+test('creates a thread row for every root', function (): void {
+ $rootA = Email::factory()->create(['id' => '']);
+ Email::factory()->replyTo($rootA)->create();
+ Email::factory()->create(['id' => '']);
+
+ app(RefreshAllThreads::class)->handle();
+
+ $threadA = Thread::find('');
+ $threadB = Thread::find('');
+ $this->assertNotNull($threadA);
+ $this->assertNotNull($threadB);
+ $this->assertSame(2, $threadA->emailCount);
+ $this->assertSame(1, $threadB->emailCount);
+});
+
+test('ignores non root emails', function (): void {
+ $root = Email::factory()->create();
+ Email::factory()->replyTo($root)->create();
+
+ app(RefreshAllThreads::class)->handle();
+
+ $this->assertSame(1, Thread::count());
+});
+
+test('replaces existing thread rows', function (): void {
+ Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
+ Thread::factory()->create([
+ 'emailId' => '',
+ 'emailNumber' => 1,
+ 'lastUpdate' => '2020-01-01 00:00:00',
+ 'emailCount' => 999,
+ 'votes' => 999,
+ ]);
+
+ app(RefreshAllThreads::class)->handle();
+
+ $thread = Thread::find('');
+ $this->assertSame(1, $thread->emailCount);
+ $this->assertSame(0, $thread->votes);
+});
diff --git a/tests/Feature/Actions/RefreshThreadTest.php b/tests/Feature/Actions/RefreshThreadTest.php
index 6467a5b..c11b98f 100644
--- a/tests/Feature/Actions/RefreshThreadTest.php
+++ b/tests/Feature/Actions/RefreshThreadTest.php
@@ -2,8 +2,6 @@
declare(strict_types=1);
-namespace Feature\Actions;
-
use App\Actions\RefreshThread;
use App\Models\Email;
use App\Models\Thread;
@@ -11,115 +9,102 @@
use App\Models\Vote;
use Illuminate\Foundation\Testing\RefreshDatabase;
use InvalidArgumentException;
-use Tests\TestCase;
-
-class RefreshThreadTest extends TestCase
-{
- use RefreshDatabase;
-
- public function test_creates_thread_row_with_email_count_and_votes(): void
- {
- $root = Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
- Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-02 11:00:00']);
- Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-03 12:00:00']);
-
- $user = User::factory()->create();
- Vote::factory()->create(['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]);
-
- app(RefreshThread::class)->handle(1);
-
- $thread = Thread::find('');
- $this->assertNotNull($thread);
- $this->assertSame(1, $thread->emailNumber);
- $this->assertSame(3, $thread->emailCount);
- $this->assertSame(1, $thread->votes);
- $this->assertSame('2026-01-03 12:00:00', $thread->lastUpdate->format('Y-m-d H:i:s'));
- }
-
- public function test_root_with_no_replies_has_count_one_and_zero_votes(): void
- {
- Email::factory()->create(['id' => '', 'number' => 10]);
-
- app(RefreshThread::class)->handle(10);
-
- $thread = Thread::find('');
- $this->assertNotNull($thread);
- $this->assertSame(1, $thread->emailCount);
- $this->assertSame(0, $thread->votes);
- }
-
- public function test_replaces_existing_thread_row(): void
- {
- Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
- Thread::factory()->create([
- 'emailId' => '',
- 'emailNumber' => 1,
- 'lastUpdate' => '2020-01-01 00:00:00',
- 'emailCount' => 999,
- 'votes' => 999,
- ]);
-
- app(RefreshThread::class)->handle(1);
-
- $thread = Thread::find('');
- $this->assertSame(1, $thread->emailCount);
- $this->assertSame(0, $thread->votes);
- $this->assertSame('2026-01-01 10:00:00', $thread->lastUpdate->format('Y-m-d H:i:s'));
- }
-
- public function test_only_refreshes_the_target_thread(): void
- {
- Email::factory()->create(['id' => '', 'number' => 1]);
- Email::factory()->create(['id' => '', 'number' => 2]);
-
- app(RefreshThread::class)->handle(1);
-
- $this->assertNotNull(Thread::find(''));
- $this->assertNull(Thread::find(''));
- }
-
- public function test_does_nothing_when_email_is_not_a_thread_root(): void
- {
- $root = Email::factory()->create(['id' => '', 'number' => 1]);
- Email::factory()->replyTo($root)->create(['number' => 2]);
-
- app(RefreshThread::class)->handle(2);
-
- $this->assertNull(Thread::find(''));
- }
-
- public function test_refreshes_by_email_id(): void
- {
- $root = Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
- Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-02 11:00:00']);
-
- app(RefreshThread::class)->handle(emailId: '');
-
- $thread = Thread::find('');
- $this->assertNotNull($thread);
- $this->assertSame(2, $thread->emailCount);
- }
-
- public function test_refreshes_only_target_thread_when_called_by_email_id(): void
- {
- Email::factory()->create(['id' => '', 'number' => 1]);
- Email::factory()->create(['id' => '', 'number' => 2]);
-
- app(RefreshThread::class)->handle(emailId: '');
-
- $this->assertNotNull(Thread::find(''));
- $this->assertNull(Thread::find(''));
- }
-
- public function test_throws_when_no_argument_is_provided(): void
- {
- $this->expectException(InvalidArgumentException::class);
- app(RefreshThread::class)->handle();
- }
-
- public function test_throws_when_both_arguments_are_provided(): void
- {
- $this->expectException(InvalidArgumentException::class);
- app(RefreshThread::class)->handle(1, '');
- }
-}
+
+uses(RefreshDatabase::class);
+
+test('creates thread row with email count and votes', function (): void {
+ $root = Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
+ Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-02 11:00:00']);
+ Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-03 12:00:00']);
+
+ $user = User::factory()->create();
+ Vote::factory()->create(['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]);
+
+ app(RefreshThread::class)->handle(1);
+
+ $thread = Thread::find('');
+ $this->assertNotNull($thread);
+ $this->assertSame(1, $thread->emailNumber);
+ $this->assertSame(3, $thread->emailCount);
+ $this->assertSame(1, $thread->votes);
+ $this->assertSame('2026-01-03 12:00:00', $thread->lastUpdate->format('Y-m-d H:i:s'));
+});
+
+test('root with no replies has count one and zero votes', function (): void {
+ Email::factory()->create(['id' => '', 'number' => 10]);
+
+ app(RefreshThread::class)->handle(10);
+
+ $thread = Thread::find('');
+ $this->assertNotNull($thread);
+ $this->assertSame(1, $thread->emailCount);
+ $this->assertSame(0, $thread->votes);
+});
+
+test('replaces existing thread row', function (): void {
+ Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
+ Thread::factory()->create([
+ 'emailId' => '',
+ 'emailNumber' => 1,
+ 'lastUpdate' => '2020-01-01 00:00:00',
+ 'emailCount' => 999,
+ 'votes' => 999,
+ ]);
+
+ app(RefreshThread::class)->handle(1);
+
+ $thread = Thread::find('');
+ $this->assertSame(1, $thread->emailCount);
+ $this->assertSame(0, $thread->votes);
+ $this->assertSame('2026-01-01 10:00:00', $thread->lastUpdate->format('Y-m-d H:i:s'));
+});
+
+test('only refreshes the target thread', function (): void {
+ Email::factory()->create(['id' => '', 'number' => 1]);
+ Email::factory()->create(['id' => '', 'number' => 2]);
+
+ app(RefreshThread::class)->handle(1);
+
+ $this->assertNotNull(Thread::find(''));
+ $this->assertNull(Thread::find(''));
+});
+
+test('does nothing when email is not a thread root', function (): void {
+ $root = Email::factory()->create(['id' => '', 'number' => 1]);
+ Email::factory()->replyTo($root)->create(['number' => 2]);
+
+ app(RefreshThread::class)->handle(2);
+
+ $this->assertNull(Thread::find(''));
+});
+
+test('refreshes by email id', function (): void {
+ $root = Email::factory()->create(['id' => '', 'number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
+ Email::factory()->replyTo($root)->create(['fetchDate' => '2026-01-02 11:00:00']);
+
+ app(RefreshThread::class)->handle(emailId: '');
+
+ $thread = Thread::find('');
+ $this->assertNotNull($thread);
+ $this->assertSame(2, $thread->emailCount);
+});
+
+test('refreshes only target thread when called by email id', function (): void {
+ Email::factory()->create(['id' => '', 'number' => 1]);
+ Email::factory()->create(['id' => '', 'number' => 2]);
+
+ app(RefreshThread::class)->handle(emailId: '');
+
+ $this->assertNotNull(Thread::find(''));
+ $this->assertNull(Thread::find(''));
+});
+
+test('throws when no argument is provided', function (): void {
+ $this->expectException(InvalidArgumentException::class);
+ app(RefreshThread::class)->handle();
+});
+
+test('throws when both arguments are provided', function (): void {
+ $this->expectException(InvalidArgumentException::class);
+ app(RefreshThread::class)->handle(1, '');
+});
diff --git a/tests/Feature/Http/EmailSourceControllerTest.php b/tests/Feature/Http/EmailSourceControllerTest.php
index d35798b..b1f2735 100644
--- a/tests/Feature/Http/EmailSourceControllerTest.php
+++ b/tests/Feature/Http/EmailSourceControllerTest.php
@@ -2,38 +2,29 @@
declare(strict_types=1);
-namespace Feature\Http;
-
use App\Models\Email;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-
-class EmailSourceControllerTest extends TestCase
-{
- use RefreshDatabase;
-
- public function test_redirects_to_login_when_unauthenticated(): void
- {
- $this->get('/email/42/source')->assertRedirect('/login');
- }
-
- public function test_returns_404_when_email_does_not_exist(): void
- {
- $this->actingAs(User::factory()->create())
- ->get('/email/9999/source')
- ->assertNotFound();
- }
-
- public function test_returns_raw_source_as_plain_text(): void
- {
- Email::factory()->create(['number' => 42, 'source' => "Subject: hi\r\n\r\nhello"]);
-
- $response = $this->actingAs(User::factory()->create())
- ->get('/email/42/source');
-
- $response->assertOk();
- $response->assertHeader('Content-Type', 'text/plain; charset=utf-8');
- $this->assertSame("Subject: hi\r\n\r\nhello", $response->getContent());
- }
-}
+
+uses(RefreshDatabase::class);
+
+test('redirects to login when unauthenticated', function (): void {
+ $this->get('/email/42/source')->assertRedirect('/login');
+});
+
+test('returns 404 when email does not exist', function (): void {
+ $this->actingAs(User::factory()->create())
+ ->get('/email/9999/source')
+ ->assertNotFound();
+});
+
+test('returns raw source as plain text', function (): void {
+ Email::factory()->create(['number' => 42, 'source' => "Subject: hi\r\n\r\nhello"]);
+
+ $response = $this->actingAs(User::factory()->create())
+ ->get('/email/42/source');
+
+ $response->assertOk();
+ $response->assertHeader('Content-Type', 'text/plain; charset=utf-8');
+ $this->assertSame("Subject: hi\r\n\r\nhello", $response->getContent());
+});
diff --git a/tests/Feature/Http/GithubControllerTest.php b/tests/Feature/Http/GithubControllerTest.php
new file mode 100644
index 0000000..3279a15
--- /dev/null
+++ b/tests/Feature/Http/GithubControllerTest.php
@@ -0,0 +1,80 @@
+create();
+
+ $response = $this->actingAs($user)->get('/login');
+
+ $response->assertRedirect('/');
+});
+
+test('unauthenticated user without code is redirected to github', function (): void {
+ Socialite::fake('github');
+
+ $response = $this->get('/login');
+
+ $response->assertRedirect('https://socialite.fake/github/authorize');
+});
+
+test('callback with code creates user and logs in', function (): void {
+ Socialite::fake('github', (new SocialiteUser)->map([
+ 'id' => '12345',
+ 'nickname' => 'octocat',
+ ]));
+
+ $response = $this->get('/login?code=abc');
+
+ $response->assertRedirect('/');
+ $user = User::where('githubId', '12345')->firstOrFail();
+ $this->assertDatabaseHas('users', ['githubId' => '12345', 'name' => 'octocat']);
+ $this->assertAuthenticatedAs($user);
+ $this->assertNotNull($user->remember_token);
+ $this->assertSame(60, mb_strlen($user->remember_token));
+});
+
+test('callback logs in existing user', function (): void {
+ $existing = User::factory()->create(['githubId' => '12345', 'name' => 'octocat']);
+ Socialite::fake('github', (new SocialiteUser)->map([
+ 'id' => '12345',
+ 'nickname' => 'octocat',
+ ]));
+
+ $response = $this->get('/login?code=abc');
+
+ $response->assertRedirect('/');
+ $this->assertAuthenticatedAs($existing);
+ $this->assertSame(1, User::count());
+});
+
+test('callback with invalid state renders error view', function (): void {
+ Socialite::fake('github', fn() => throw new InvalidStateException);
+
+ $response = $this->get('/login?code=abc');
+
+ $response->assertStatus(400);
+ $response->assertViewIs('auth.login-error');
+ $response->assertViewHas('error', 'Invalid state');
+ $this->assertGuest();
+});
+
+test('callback with socialite failure renders error view', function (): void {
+ Socialite::fake('github', fn() => throw new RuntimeException('boom'));
+
+ $response = $this->get('/login?code=abc');
+
+ $response->assertStatus(400);
+ $response->assertViewIs('auth.login-error');
+ $response->assertViewHas('error', 'boom');
+ $this->assertGuest();
+});
diff --git a/tests/Feature/Http/HomeControllerTest.php b/tests/Feature/Http/HomeControllerTest.php
index daa6e19..a1d350c 100644
--- a/tests/Feature/Http/HomeControllerTest.php
+++ b/tests/Feature/Http/HomeControllerTest.php
@@ -2,47 +2,25 @@
declare(strict_types=1);
-namespace Feature\Http;
-
-use App\Models\Email;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-
-class HomeControllerTest extends TestCase
-{
- use RefreshDatabase;
-
- public function test_renders_home_for_guest(): void
- {
- $response = $this->get('/');
-
- $response->assertOk();
- $response->assertViewIs('home');
- $response->assertViewHas('user', null);
- $response->assertViewHas('page', 1);
- $response->assertSee('instantsearch.js/2/instantsearch.min.js', false);
- $response->assertSee('function init_search()', false);
- }
- public function test_renders_home_for_authenticated_user(): void
- {
- $user = User::factory()->create();
+uses(RefreshDatabase::class);
- $response = $this->actingAs($user)->get('/');
+test('renders home for guest', function (): void {
+ $response = $this->get('/');
- $response->assertOk();
- $response->assertViewHas('user', fn($viewUser) => $viewUser?->is($user));
- }
+ $response->assertOk();
+ $response->assertSee('mailing list to the outside', false);
+ $response->assertSee('id="search-input"', false);
+});
- public function test_page_count_reflects_thread_root_count(): void
- {
- Email::factory()->count(25)->create();
+test('renders home for authenticated user', function (): void {
+ $user = User::factory()->create(['name' => 'octocat']);
- $response = $this->get('/');
+ $response = $this->actingAs($user)->get('/');
- $response->assertOk();
- // 25 threads / 20 per page = 2 pages
- $response->assertViewHas('pageCount', 2);
- }
-}
+ $response->assertOk();
+ // The nav greets the logged-in user by name.
+ $response->assertSee('octocat');
+});
diff --git a/tests/Feature/Http/LegacyThreadRedirectControllerTest.php b/tests/Feature/Http/LegacyThreadRedirectControllerTest.php
index d40057b..95a631a 100644
--- a/tests/Feature/Http/LegacyThreadRedirectControllerTest.php
+++ b/tests/Feature/Http/LegacyThreadRedirectControllerTest.php
@@ -2,46 +2,37 @@
declare(strict_types=1);
-namespace Feature\Http;
-
use App\Models\Email;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
-use Tests\TestCase;
-
-class LegacyThreadRedirectControllerTest extends TestCase
-{
- use RefreshDatabase;
-
- public function test_returns_404_when_legacy_thread_id_unknown(): void
- {
- $this->get('/thread/123')->assertNotFound();
- }
-
- public function test_returns_404_when_no_email_matches_subject(): void
- {
- DB::table('threads_old')->insert(['id' => 1, 'subject' => 'No match']);
-
- $this->get('/thread/1')->assertNotFound();
- }
-
- public function test_redirects_to_latest_matching_thread_root(): void
- {
- DB::table('threads_old')->insert(['id' => 1, 'subject' => 'Reused subject']);
- Email::factory()->create([
- 'subject' => 'Reused subject',
- 'number' => 100,
- 'date' => '2020-01-01 00:00:00',
- ]);
- Email::factory()->create([
- 'subject' => 'Reused subject',
- 'number' => 200,
- 'date' => '2024-01-01 00:00:00',
- ]);
-
- $response = $this->get('/thread/1');
-
- $response->assertRedirect('/message/200');
- $response->assertStatus(301);
- }
-}
+
+uses(RefreshDatabase::class);
+
+test('returns 404 when legacy thread id unknown', function (): void {
+ $this->get('/thread/123')->assertNotFound();
+});
+
+test('returns 404 when no email matches subject', function (): void {
+ DB::table('threads_old')->insert(['id' => 1, 'subject' => 'No match']);
+
+ $this->get('/thread/1')->assertNotFound();
+});
+
+test('redirects to latest matching thread root', function (): void {
+ DB::table('threads_old')->insert(['id' => 1, 'subject' => 'Reused subject']);
+ Email::factory()->create([
+ 'subject' => 'Reused subject',
+ 'number' => 100,
+ 'date' => '2020-01-01 00:00:00',
+ ]);
+ Email::factory()->create([
+ 'subject' => 'Reused subject',
+ 'number' => 200,
+ 'date' => '2024-01-01 00:00:00',
+ ]);
+
+ $response = $this->get('/thread/1');
+
+ $response->assertRedirect('/message/200');
+ $response->assertStatus(301);
+});
diff --git a/tests/Feature/Http/LoginControllerTest.php b/tests/Feature/Http/LoginControllerTest.php
deleted file mode 100644
index daa1938..0000000
--- a/tests/Feature/Http/LoginControllerTest.php
+++ /dev/null
@@ -1,92 +0,0 @@
-create();
-
- $response = $this->actingAs($user)->get('/login');
-
- $response->assertRedirect('/');
- }
-
- public function test_unauthenticated_user_without_code_is_redirected_to_github(): void
- {
- Socialite::fake('github');
-
- $response = $this->get('/login');
-
- $response->assertRedirect('https://socialite.fake/github/authorize');
- }
-
- public function test_callback_with_code_creates_user_and_logs_in(): void
- {
- Socialite::fake('github', (new SocialiteUser)->map([
- 'id' => '12345',
- 'nickname' => 'octocat',
- ]));
-
- $response = $this->get('/login?code=abc');
-
- $response->assertRedirect('/');
- $user = User::where('githubId', '12345')->firstOrFail();
- $this->assertDatabaseHas('users', ['githubId' => '12345', 'name' => 'octocat']);
- $this->assertAuthenticatedAs($user);
- $this->assertNotNull($user->remember_token);
- $this->assertSame(60, mb_strlen($user->remember_token));
- }
-
- public function test_callback_logs_in_existing_user(): void
- {
- $existing = User::factory()->create(['githubId' => '12345', 'name' => 'octocat']);
- Socialite::fake('github', (new SocialiteUser)->map([
- 'id' => '12345',
- 'nickname' => 'octocat',
- ]));
-
- $response = $this->get('/login?code=abc');
-
- $response->assertRedirect('/');
- $this->assertAuthenticatedAs($existing);
- $this->assertSame(1, User::count());
- }
-
- public function test_callback_with_invalid_state_renders_error_view(): void
- {
- Socialite::fake('github', fn() => throw new InvalidStateException);
-
- $response = $this->get('/login?code=abc');
-
- $response->assertStatus(400);
- $response->assertViewIs('auth.login-error');
- $response->assertViewHas('error', 'Invalid state');
- $this->assertGuest();
- }
-
- public function test_callback_with_socialite_failure_renders_error_view(): void
- {
- Socialite::fake('github', fn() => throw new RuntimeException('boom'));
-
- $response = $this->get('/login?code=abc');
-
- $response->assertStatus(400);
- $response->assertViewIs('auth.login-error');
- $response->assertViewHas('error', 'boom');
- $this->assertGuest();
- }
-}
diff --git a/tests/Feature/Http/LogoutControllerTest.php b/tests/Feature/Http/LogoutControllerTest.php
index ff75455..a76ece5 100644
--- a/tests/Feature/Http/LogoutControllerTest.php
+++ b/tests/Feature/Http/LogoutControllerTest.php
@@ -2,31 +2,23 @@
declare(strict_types=1);
-namespace Feature\Http;
-
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-class LogoutControllerTest extends TestCase
-{
- use RefreshDatabase;
+uses(RefreshDatabase::class);
- public function test_logs_out_authenticated_user(): void
- {
- $user = User::factory()->create();
+test('logs out authenticated user', function (): void {
+ $user = User::factory()->create();
- $response = $this->actingAs($user)->post('/logout');
+ $response = $this->actingAs($user)->post('/logout');
- $response->assertRedirect('/');
- $this->assertGuest();
- }
+ $response->assertRedirect('/');
+ $this->assertGuest();
+});
- public function test_logout_when_already_guest_is_safe(): void
- {
- $response = $this->post('/logout');
+test('logout when already guest is safe', function (): void {
+ $response = $this->post('/logout');
- $response->assertRedirect('/');
- $this->assertGuest();
- }
-}
+ $response->assertRedirect('/');
+ $this->assertGuest();
+});
diff --git a/tests/Feature/Http/NewsControllerTest.php b/tests/Feature/Http/NewsControllerTest.php
index 9cc20e2..311850d 100644
--- a/tests/Feature/Http/NewsControllerTest.php
+++ b/tests/Feature/Http/NewsControllerTest.php
@@ -2,17 +2,9 @@
declare(strict_types=1);
-namespace Feature\Http;
+test('renders news page', function (): void {
+ $response = $this->get('/news');
-use Tests\TestCase;
-
-class NewsControllerTest extends TestCase
-{
- public function test_renders_news_page(): void
- {
- $response = $this->get('/news');
-
- $response->assertOk();
- $response->assertViewIs('news');
- }
-}
+ $response->assertOk();
+ $response->assertSee("What's new?", false);
+});
diff --git a/tests/Feature/Http/RssControllerTest.php b/tests/Feature/Http/RssControllerTest.php
index 27e0e3d..cca88d9 100644
--- a/tests/Feature/Http/RssControllerTest.php
+++ b/tests/Feature/Http/RssControllerTest.php
@@ -2,39 +2,31 @@
declare(strict_types=1);
-namespace Feature\Http;
-
use App\Models\Email;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-
-class RssControllerTest extends TestCase
-{
- use RefreshDatabase;
-
- public function test_returns_rss_xml(): void
- {
- Email::factory()->create(['number' => 1, 'subject' => 'First']);
- Email::factory()->create(['number' => 2, 'subject' => 'Second']);
-
- $response = $this->get('/rss');
-
- $response->assertOk();
- $response->assertHeader('Content-Type', 'text/xml; charset=utf-8');
- $this->assertStringContainsString('getContent());
- $this->assertStringContainsString('First', $response->getContent());
- $this->assertStringContainsString('Second', $response->getContent());
- }
-
- public function test_since_query_filters_older_emails(): void
- {
- Email::factory()->create(['number' => 1, 'subject' => 'Old']);
- Email::factory()->create(['number' => 5, 'subject' => 'New']);
-
- $response = $this->get('/rss?since=2');
-
- $response->assertOk();
- $this->assertStringNotContainsString('Old', $response->getContent());
- $this->assertStringContainsString('New', $response->getContent());
- }
-}
+
+uses(RefreshDatabase::class);
+
+test('returns rss xml', function (): void {
+ Email::factory()->create(['number' => 1, 'subject' => 'First']);
+ Email::factory()->create(['number' => 2, 'subject' => 'Second']);
+
+ $response = $this->get('/rss');
+
+ $response->assertOk();
+ $response->assertHeader('Content-Type', 'text/xml; charset=utf-8');
+ $this->assertStringContainsString('getContent());
+ $this->assertStringContainsString('First', $response->getContent());
+ $this->assertStringContainsString('Second', $response->getContent());
+});
+
+test('since query filters older emails', function (): void {
+ Email::factory()->create(['number' => 1, 'subject' => 'Old']);
+ Email::factory()->create(['number' => 5, 'subject' => 'New']);
+
+ $response = $this->get('/rss?since=2');
+
+ $response->assertOk();
+ $this->assertStringNotContainsString('Old', $response->getContent());
+ $this->assertStringContainsString('New', $response->getContent());
+});
diff --git a/tests/Feature/Http/RssRfcControllerTest.php b/tests/Feature/Http/RssRfcControllerTest.php
index 8ea9ebd..b1aebda 100644
--- a/tests/Feature/Http/RssRfcControllerTest.php
+++ b/tests/Feature/Http/RssRfcControllerTest.php
@@ -2,21 +2,14 @@
declare(strict_types=1);
-namespace Feature\Http;
-
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-class RssRfcControllerTest extends TestCase
-{
- use RefreshDatabase;
+uses(RefreshDatabase::class);
- public function test_returns_rss_xml(): void
- {
- $response = $this->get('/rss-rfc');
+test('returns rss xml', function (): void {
+ $response = $this->get('/rss-rfc');
- $response->assertOk();
- $response->assertHeader('Content-Type', 'text/xml; charset=utf-8');
- $this->assertStringContainsString('getContent());
- }
-}
+ $response->assertOk();
+ $response->assertHeader('Content-Type', 'text/xml; charset=utf-8');
+ $this->assertStringContainsString('getContent());
+});
diff --git a/tests/Feature/Http/StatsControllerTest.php b/tests/Feature/Http/StatsControllerTest.php
index 24495c3..ed2b779 100644
--- a/tests/Feature/Http/StatsControllerTest.php
+++ b/tests/Feature/Http/StatsControllerTest.php
@@ -2,30 +2,22 @@
declare(strict_types=1);
-namespace Feature\Http;
-
use App\Models\Email;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-class StatsControllerTest extends TestCase
-{
- use RefreshDatabase;
+uses(RefreshDatabase::class);
- public function test_renders_stats_with_counts(): void
- {
- User::factory()->count(3)->create();
- $root = Email::factory()->create();
- Email::factory()->replyTo($root)->create();
- Email::factory()->replyTo($root)->create();
+test('renders stats with counts', function (): void {
+ User::factory()->count(3)->create();
+ $root = Email::factory()->create();
+ Email::factory()->replyTo($root)->create();
+ Email::factory()->replyTo($root)->create();
- $response = $this->get('/stats');
+ $response = $this->get('/stats');
- $response->assertOk();
- $response->assertViewIs('stats');
- $response->assertViewHas('userCount', 3);
- $response->assertViewHas('threadCount', 1);
- $response->assertViewHas('emailCount', 3);
- }
-}
+ $response->assertOk();
+ $response->assertSee('3 users');
+ $response->assertSee('1 threads');
+ $response->assertSee('3 emails');
+});
diff --git a/tests/Feature/Http/ThreadControllerTest.php b/tests/Feature/Http/ThreadControllerTest.php
index fba03cd..22030cf 100644
--- a/tests/Feature/Http/ThreadControllerTest.php
+++ b/tests/Feature/Http/ThreadControllerTest.php
@@ -2,80 +2,65 @@
declare(strict_types=1);
-namespace Feature\Http;
-
use App\Models\Email;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-class ThreadControllerTest extends TestCase
-{
- use RefreshDatabase;
+uses(RefreshDatabase::class);
- public function test_returns_404_when_email_does_not_exist(): void
- {
- $response = $this->get('/message/12345');
+test('returns 404 when email does not exist', function (): void {
+ $response = $this->get('/message/12345');
- $response->assertNotFound();
- }
+ $response->assertNotFound();
+});
- public function test_renders_thread_view_for_root_email(): void
- {
- Email::factory()->create(['number' => 100, 'subject' => 'Hello world']);
+test('renders thread view for root email', function (): void {
+ Email::factory()->create(['number' => 100, 'subject' => 'Hello world']);
- $response = $this->get('/message/100');
+ $response = $this->get('/message/100');
- $response->assertOk();
- $response->assertViewIs('thread');
- $response->assertViewHas('subject', 'Hello world');
- $response->assertViewHas('threadId', 100);
- }
+ $response->assertOk();
+ $response->assertSee('Hello world');
+});
- public function test_redirects_replies_to_thread_root_with_anchor(): void
- {
- $root = Email::factory()->create(['number' => 100]);
- $reply = Email::factory()->replyTo($root)->create(['number' => 101]);
+test('redirects replies to thread root with anchor', function (): void {
+ $root = Email::factory()->create(['number' => 100]);
+ $reply = Email::factory()->replyTo($root)->create(['number' => 101]);
- $response = $this->get('/message/' . $reply->number);
+ $response = $this->get('/message/' . $reply->number);
- $response->assertRedirect("/message/{$root->number}#{$reply->number}");
- }
+ $response->assertRedirect("/message/{$root->number}#{$reply->number}");
+});
- public function test_renders_orphan_reply_when_thread_root_missing(): void
- {
- Email::factory()->create([
- 'number' => 200,
- 'id' => '',
- 'threadId' => '',
- 'isThreadRoot' => false,
- ]);
+test('renders orphan reply when thread root missing', function (): void {
+ Email::factory()->create([
+ 'number' => 200,
+ 'id' => '',
+ 'threadId' => '',
+ 'isThreadRoot' => false,
+ ]);
- $response = $this->get('/message/200');
+ $response = $this->get('/message/200');
- $response->assertOk();
- $response->assertViewIs('thread');
- }
+ $response->assertOk();
+});
- public function test_marks_email_as_read_for_authenticated_user(): void
- {
- Email::factory()->create(['id' => '', 'number' => 300]);
- $user = User::factory()->create();
+test('marks email as read for authenticated user', function (): void {
+ Email::factory()->create(['id' => '', 'number' => 300]);
+ $user = User::factory()->create();
- $this->actingAs($user)->get('/message/300')->assertOk();
+ $this->actingAs($user)->get('/message/300')->assertOk();
- $this->assertDatabaseHas('user_emails_read', [
- 'emailId' => '',
- 'userId' => $user->id,
- ]);
- }
+ $this->assertDatabaseHas('user_emails_read', [
+ 'emailId' => '',
+ 'userId' => $user->id,
+ ]);
+});
- public function test_does_not_mark_as_read_for_guest(): void
- {
- Email::factory()->create(['id' => '', 'number' => 301]);
+test('does not mark as read for guest', function (): void {
+ Email::factory()->create(['id' => '', 'number' => 301]);
- $this->get('/message/301')->assertOk();
+ $this->get('/message/301')->assertOk();
- $this->assertDatabaseCount('user_emails_read', 0);
- }
-}
+ $this->assertDatabaseCount('user_emails_read', 0);
+});
diff --git a/tests/Feature/Http/VoteControllerTest.php b/tests/Feature/Http/VoteControllerTest.php
deleted file mode 100644
index 5eec9d7..0000000
--- a/tests/Feature/Http/VoteControllerTest.php
+++ /dev/null
@@ -1,72 +0,0 @@
-postJson('/votes/1', ['value' => 1]);
-
- $response->assertStatus(401);
- $this->assertSame('"You must be authenticated"', $response->getContent());
- }
-
- public function test_rejects_value_greater_than_one(): void
- {
- $user = User::factory()->create();
-
- $response = $this->actingAs($user)->postJson('/votes/1', ['value' => 2]);
-
- $response->assertStatus(400);
- $this->assertSame('"Invalid value"', $response->getContent());
- }
-
- public function test_rejects_value_less_than_minus_one(): void
- {
- $user = User::factory()->create();
-
- $response = $this->actingAs($user)->postJson('/votes/1', ['value' => -2]);
-
- $response->assertStatus(400);
- }
-
- public function test_records_vote_and_returns_new_total(): void
- {
- Email::factory()->create(['number' => 42]);
- $user = User::factory()->create();
-
- $response = $this->actingAs($user)->postJson('/votes/42', ['value' => 1]);
-
- $response->assertOk();
- $response->assertExactJson(['newTotal' => 1, 'newValue' => 1]);
- $this->assertDatabaseHas('votes', [
- 'userId' => $user->id,
- 'emailNumber' => 42,
- 'value' => 1,
- ]);
- }
-
- public function test_zero_value_clears_existing_vote(): void
- {
- Email::factory()->create(['number' => 42]);
- $user = User::factory()->create();
- $this->actingAs($user)->postJson('/votes/42', ['value' => 1]);
-
- $response = $this->actingAs($user)->postJson('/votes/42', ['value' => 0]);
-
- $response->assertOk();
- $response->assertJson(['newValue' => 0]);
- $this->assertSame(0, Vote::where('userId', $user->id)->where('emailNumber', 42)->sum('value'));
- }
-}
diff --git a/tests/Feature/Livewire/ThreadListTest.php b/tests/Feature/Livewire/ThreadListTest.php
new file mode 100644
index 0000000..035d2c5
--- /dev/null
+++ b/tests/Feature/Livewire/ThreadListTest.php
@@ -0,0 +1,94 @@
+count(25)->create();
+ app(RefreshAllThreads::class)->handle();
+
+ // 25 threads / 20 per page = 2 pages
+ Volt::test('thread-list', ['mode' => 'latest'])
+ ->assertViewHas('pageCount', 2);
+});
+
+test('guest cannot vote', function (): void {
+ Email::factory()->create(['number' => 42]);
+
+ Volt::test('thread-list', ['mode' => 'latest'])
+ ->call('vote', 42, 1);
+
+ $this->assertDatabaseCount('votes', 0);
+});
+
+test('authenticated user can vote', function (): void {
+ Email::factory()->create(['number' => 42]);
+ $user = User::factory()->create();
+
+ Volt::actingAs($user)
+ ->test('thread-list', ['mode' => 'latest'])
+ ->call('vote', 42, 1);
+
+ $this->assertDatabaseHas('votes', [
+ 'userId' => $user->id,
+ 'emailNumber' => 42,
+ 'value' => 1,
+ ]);
+});
+
+test('clicking the active vote again removes it', function (): void {
+ Email::factory()->create(['number' => 42]);
+ $user = User::factory()->create();
+
+ $component = Volt::actingAs($user)->test('thread-list', ['mode' => 'latest']);
+ $component->call('vote', 42, 1);
+ $component->call('vote', 42, 1);
+
+ $this->assertSame(0, (int) Vote::where('userId', $user->id)->where('emailNumber', 42)->sum('value'));
+});
+
+test('top mode lists recently updated threads with positive votes', function (): void {
+ Email::factory()->create([
+ 'number' => 42,
+ 'subject' => 'Voted recent thread',
+ 'fetchDate' => now()->subDays(3),
+ ]);
+ Vote::factory()->create(['userId' => User::factory()->create()->id, 'emailNumber' => 42, 'value' => 1]);
+ app(RefreshAllThreads::class)->handle();
+
+ Volt::test('thread-list', ['mode' => 'top'])
+ ->assertSee('Voted recent thread');
+});
+
+test('top mode excludes threads without positive votes', function (): void {
+ Email::factory()->create([
+ 'number' => 42,
+ 'subject' => 'Unvoted recent thread',
+ 'fetchDate' => now()->subDays(3),
+ ]);
+ app(RefreshAllThreads::class)->handle();
+
+ Volt::test('thread-list', ['mode' => 'top'])
+ ->assertDontSee('Unvoted recent thread');
+});
+
+test('top mode excludes voted threads older than a month', function (): void {
+ Email::factory()->create([
+ 'number' => 42,
+ 'subject' => 'Voted stale thread',
+ 'fetchDate' => now()->subMonths(2),
+ ]);
+ Vote::factory()->create(['userId' => User::factory()->create()->id, 'emailNumber' => 42, 'value' => 1]);
+ app(RefreshAllThreads::class)->handle();
+
+ Volt::test('thread-list', ['mode' => 'top'])
+ ->assertDontSee('Voted stale thread');
+});
diff --git a/tests/Feature/NoIndexTest.php b/tests/Feature/NoIndexTest.php
index 9a9f1fa..f091986 100644
--- a/tests/Feature/NoIndexTest.php
+++ b/tests/Feature/NoIndexTest.php
@@ -2,59 +2,44 @@
declare(strict_types=1);
-namespace Feature;
-
-use App\Providers\AppServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Illuminate\Support\Facades\View;
-use Tests\TestCase;
-class NoIndexTest extends TestCase
-{
- use RefreshDatabase;
+uses(RefreshDatabase::class);
- public function test_noindex_meta_tag_is_rendered_when_enabled(): void
- {
- View::share('noIndex', true);
+test('noindex meta tag is rendered when enabled', function (): void {
+ config()->set('externals.no_index', true);
- $response = $this->get('/');
+ $response = $this->get('/');
- $response->assertOk();
- $response->assertSee(' ', false);
- }
+ $response->assertOk();
+ $response->assertSee(' ', false);
+});
- public function test_noindex_meta_tag_is_not_rendered_when_disabled(): void
- {
- View::share('noIndex', false);
+test('noindex meta tag is not rendered when disabled', function (): void {
+ config()->set('externals.no_index', false);
- $response = $this->get('/');
+ $response = $this->get('/');
- $response->assertOk();
- $response->assertDontSee(' ', false);
- }
+ $response->assertOk();
+ $response->assertDontSee(' ', false);
+});
- public function test_noindex_config_truthy_shares_true_to_views(): void
- {
- config()->set('externals.no_index', '1');
- $this->app->register(AppServiceProvider::class, force: true);
+test('noindex config truthy renders meta tag', function (): void {
+ config()->set('externals.no_index', '1');
- $this->assertTrue(View::shared('noIndex'));
- }
+ $this->get('/')->assertSee(' ', false);
+});
- public function test_noindex_config_falsy_shares_false_to_views(): void
- {
- config()->set('externals.no_index', '0');
- $this->app->register(AppServiceProvider::class, force: true);
+test('noindex config falsy does not render meta tag', function (): void {
+ config()->set('externals.no_index', '0');
- $this->assertFalse(View::shared('noIndex'));
- }
+ $this->get('/')->assertDontSee(' ', false);
+});
- public function test_noindex_defaults_to_false_when_env_var_is_absent(): void
- {
- // Matches the production configuration where GOOGLE_NO_INDEX is not set.
- // The default of `env('GOOGLE_NO_INDEX', false)` resolves to false.
- $config = require config_path('externals.php');
+test('noindex defaults to false when env var is absent', function (): void {
+ // Matches the production configuration where GOOGLE_NO_INDEX is not set.
+ // The default of `env('GOOGLE_NO_INDEX', false)` resolves to false.
+ $config = require config_path('externals.php');
- $this->assertFalse($config['no_index']);
- }
-}
+ $this->assertFalse($config['no_index']);
+});
diff --git a/tests/Feature/Services/Email/ThreadQueryTest.php b/tests/Feature/Services/Email/ThreadQueryTest.php
index 48008e6..9a50f4a 100644
--- a/tests/Feature/Services/Email/ThreadQueryTest.php
+++ b/tests/Feature/Services/Email/ThreadQueryTest.php
@@ -2,104 +2,92 @@
declare(strict_types=1);
-namespace Feature\Services\Email;
-
use App\Actions\RefreshAllThreads;
use App\Models\Email;
use App\Models\User;
use App\Models\Vote;
use App\Services\Email\ThreadQuery;
use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-
-class ThreadQueryTest extends TestCase
-{
- use RefreshDatabase;
-
- public function test_find_latest_threads_orders_by_last_update_desc(): void
- {
- Email::factory()->create(['number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
- Email::factory()->create(['number' => 2, 'fetchDate' => '2026-03-01 10:00:00']);
- Email::factory()->create(['number' => 3, 'fetchDate' => '2026-02-01 10:00:00']);
- app(RefreshAllThreads::class)->handle();
-
- $threads = (new ThreadQuery)->findLatestThreads(1, null);
-
- $this->assertCount(3, $threads);
- $this->assertSame(2, $threads[0]['number']);
- $this->assertSame(3, $threads[1]['number']);
- $this->assertSame(1, $threads[2]['number']);
- }
-
- public function test_find_latest_rfc_threads_only_includes_subjects_containing_rfc(): void
- {
- Email::factory()->create(['subject' => '[RFC] My idea']);
- Email::factory()->create(['subject' => 'Just a discussion']);
- app(RefreshAllThreads::class)->handle();
-
- $threads = (new ThreadQuery)->findLatestRfcThreads();
-
- $this->assertCount(1, $threads);
- $this->assertSame('[RFC] My idea', $threads[0]['subject']);
- }
-
- public function test_find_latest_threads_paginates_results(): void
- {
- // 21 threads so the second page contains exactly one
- Email::factory()->count(21)->create();
- app(RefreshAllThreads::class)->handle();
-
- $page1 = (new ThreadQuery)->findLatestThreads(1, null);
- $page2 = (new ThreadQuery)->findLatestThreads(2, null);
-
- $this->assertCount(20, $page1);
- $this->assertCount(1, $page2);
- }
-
- public function test_find_latest_threads_returns_user_vote_when_user_is_given(): void
- {
- Email::factory()->create(['number' => 1]);
- $user = User::factory()->create();
- Vote::factory()->create(['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]);
- app(RefreshAllThreads::class)->handle();
-
- $threads = (new ThreadQuery)->findLatestThreads(1, $user);
-
- $this->assertSame(1, (int) $threads[0]['userVote']);
- }
-
- public function test_get_thread_view_builds_tree_of_replies(): void
- {
- $root = Email::factory()->create(['id' => '', 'date' => '2026-01-01 10:00:00']);
- $reply1 = Email::factory()->replyTo($root)->create(['id' => '', 'date' => '2026-01-02 10:00:00']);
- Email::factory()->replyTo($reply1)->create(['id' => '', 'date' => '2026-01-03 10:00:00']);
- Email::factory()->replyTo($root)->create(['id' => '', 'date' => '2026-01-04 10:00:00']);
-
- $rootItems = (new ThreadQuery)->getThreadView($root);
-
- $this->assertCount(1, $rootItems);
- $this->assertSame('', $rootItems[0]->email->id);
- $this->assertCount(2, $rootItems[0]->replies);
- $this->assertSame('', $rootItems[0]->replies[0]->email->id);
- $this->assertCount(1, $rootItems[0]->replies[0]->replies);
- $this->assertSame('', $rootItems[0]->replies[0]->replies[0]->email->id);
- $this->assertSame('', $rootItems[0]->replies[1]->email->id);
- $this->assertCount(0, $rootItems[0]->replies[1]->replies);
- }
-
- public function test_get_thread_view_promotes_replies_with_unknown_parent_to_root(): void
- {
- // The "real" thread root is missing — orphaned replies should still surface.
- $orphan = Email::factory()->create([
- 'id' => '',
- 'threadId' => '',
- 'isThreadRoot' => false,
- 'inReplyTo' => '',
- ]);
-
- $rootItems = (new ThreadQuery)->getThreadView($orphan);
-
- $this->assertCount(1, $rootItems);
- $this->assertSame('', $rootItems[0]->email->id);
- }
-}
+
+uses(RefreshDatabase::class);
+
+test('find latest threads orders by last update desc', function (): void {
+ Email::factory()->create(['number' => 1, 'fetchDate' => '2026-01-01 10:00:00']);
+ Email::factory()->create(['number' => 2, 'fetchDate' => '2026-03-01 10:00:00']);
+ Email::factory()->create(['number' => 3, 'fetchDate' => '2026-02-01 10:00:00']);
+ app(RefreshAllThreads::class)->handle();
+
+ $threads = (new ThreadQuery)->findLatestThreads(1, null);
+
+ $this->assertCount(3, $threads);
+ $this->assertSame(2, $threads[0]->number);
+ $this->assertSame(3, $threads[1]->number);
+ $this->assertSame(1, $threads[2]->number);
+});
+
+test('find latest rfc threads only includes subjects containing rfc', function (): void {
+ Email::factory()->create(['subject' => '[RFC] My idea']);
+ Email::factory()->create(['subject' => 'Just a discussion']);
+ app(RefreshAllThreads::class)->handle();
+
+ $threads = (new ThreadQuery)->findLatestRfcThreads();
+
+ $this->assertCount(1, $threads);
+ $this->assertSame('[RFC] My idea', $threads[0]->subject);
+});
+
+test('find latest threads paginates results', function (): void {
+ // 21 threads so the second page contains exactly one
+ Email::factory()->count(21)->create();
+ app(RefreshAllThreads::class)->handle();
+
+ $page1 = (new ThreadQuery)->findLatestThreads(1, null);
+ $page2 = (new ThreadQuery)->findLatestThreads(2, null);
+
+ $this->assertCount(20, $page1);
+ $this->assertCount(1, $page2);
+});
+
+test('find latest threads returns user vote when user is given', function (): void {
+ Email::factory()->create(['number' => 1]);
+ $user = User::factory()->create();
+ Vote::factory()->create(['userId' => $user->id, 'emailNumber' => 1, 'value' => 1]);
+ app(RefreshAllThreads::class)->handle();
+
+ $threads = (new ThreadQuery)->findLatestThreads(1, $user);
+
+ $this->assertSame(1, $threads[0]->userVote);
+});
+
+test('get thread view builds tree of replies', function (): void {
+ $root = Email::factory()->create(['id' => '', 'date' => '2026-01-01 10:00:00']);
+ $reply1 = Email::factory()->replyTo($root)->create(['id' => '', 'date' => '2026-01-02 10:00:00']);
+ Email::factory()->replyTo($reply1)->create(['id' => '', 'date' => '2026-01-03 10:00:00']);
+ Email::factory()->replyTo($root)->create(['id' => '', 'date' => '2026-01-04 10:00:00']);
+
+ $rootItems = (new ThreadQuery)->getThreadView($root);
+
+ $this->assertCount(1, $rootItems);
+ $this->assertSame('', $rootItems[0]->email->id);
+ $this->assertCount(2, $rootItems[0]->replies);
+ $this->assertSame('', $rootItems[0]->replies[0]->email->id);
+ $this->assertCount(1, $rootItems[0]->replies[0]->replies);
+ $this->assertSame('', $rootItems[0]->replies[0]->replies[0]->email->id);
+ $this->assertSame('', $rootItems[0]->replies[1]->email->id);
+ $this->assertCount(0, $rootItems[0]->replies[1]->replies);
+});
+
+test('get thread view promotes replies with unknown parent to root', function (): void {
+ // The "real" thread root is missing — orphaned replies should still surface.
+ $orphan = Email::factory()->create([
+ 'id' => '',
+ 'threadId' => '',
+ 'isThreadRoot' => false,
+ 'inReplyTo' => '',
+ ]);
+
+ $rootItems = (new ThreadQuery)->getThreadView($orphan);
+
+ $this->assertCount(1, $rootItems);
+ $this->assertSame('', $rootItems[0]->email->id);
+});
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..3566ea6
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,7 @@
+extend(TestCase::class)->in('Feature', 'Unit');
diff --git a/tests/Unit/Services/Email/EmailAddressParserTest.php b/tests/Unit/Services/Email/EmailAddressParserTest.php
index 521b8c0..36037b1 100644
--- a/tests/Unit/Services/Email/EmailAddressParserTest.php
+++ b/tests/Unit/Services/Email/EmailAddressParserTest.php
@@ -2,88 +2,73 @@
declare(strict_types=1);
-namespace Tests\Unit\Services\Email;
-
use App\Services\Email\EmailAddressParser;
-use Tests\TestCase;
-
-class EmailAddressParserTest extends TestCase
-{
- public function test_should_parse_simple_email(): void
- {
- $identities = (new EmailAddressParser('john@example.com'))->parse();
-
- $this->assertCount(1, $identities);
- $this->assertSame('john@example.com', $identities[0]->email);
- $this->assertNull($identities[0]->name);
- }
-
- public function test_should_parse_email_with_name(): void
- {
- $identities = (new EmailAddressParser('John Doe '))->parse();
-
- $this->assertCount(1, $identities);
- $this->assertSame('john@example.com', $identities[0]->email);
- $this->assertSame('John Doe', $identities[0]->name);
- }
-
- public function test_should_parse_email_with_quoted_name(): void
- {
- $identities = (new EmailAddressParser('"John Doe" '))->parse();
-
- $this->assertCount(1, $identities);
- $this->assertSame('john@example.com', $identities[0]->email);
- $this->assertSame('John Doe', $identities[0]->name);
- }
-
- public function test_should_parse_email_with_parenthesised_name(): void
- {
- $identities = (new EmailAddressParser('john@example.com (John Doe)'))->parse();
-
- $this->assertCount(1, $identities);
- $this->assertSame('john@example.com', $identities[0]->email);
- $this->assertSame('John Doe', $identities[0]->name);
- }
-
- public function test_should_strip_original_marker(): void
- {
- $identities = (new EmailAddressParser('John Doe (original) '))->parse();
-
- $this->assertCount(1, $identities);
- $this->assertSame('john@example.com', $identities[0]->email);
- $this->assertSame('John Doe', $identities[0]->name);
- }
-
- public function test_should_collapse_dotted_email(): void
- {
- $identities = (new EmailAddressParser('john . doe@example.com'))->parse();
-
- $this->assertCount(1, $identities);
- $this->assertSame('john.doe@example.com', $identities[0]->email);
- }
-
- public function test_should_invalidate_malformed_email(): void
- {
- $identities = (new EmailAddressParser('not-an-email'))->parse();
-
- $this->assertCount(1, $identities);
- $this->assertNull($identities[0]->email);
- }
-
- public function test_should_skip_short_names(): void
- {
- $identities = (new EmailAddressParser('Jo '))->parse();
-
- $this->assertCount(1, $identities);
- $this->assertSame('jo@example.com', $identities[0]->email);
- $this->assertNull($identities[0]->name);
- }
-
- public function test_should_skip_name_containing_url(): void
- {
- $identities = (new EmailAddressParser('http://example.com '))->parse();
-
- $this->assertSame('john@example.com', $identities[0]->email);
- $this->assertNull($identities[0]->name);
- }
-}
+
+test('should parse simple email', function (): void {
+ $identities = (new EmailAddressParser('john@example.com'))->parse();
+
+ $this->assertCount(1, $identities);
+ $this->assertSame('john@example.com', $identities[0]->email);
+ $this->assertNull($identities[0]->name);
+});
+
+test('should parse email with name', function (): void {
+ $identities = (new EmailAddressParser('John Doe '))->parse();
+
+ $this->assertCount(1, $identities);
+ $this->assertSame('john@example.com', $identities[0]->email);
+ $this->assertSame('John Doe', $identities[0]->name);
+});
+
+test('should parse email with quoted name', function (): void {
+ $identities = (new EmailAddressParser('"John Doe" '))->parse();
+
+ $this->assertCount(1, $identities);
+ $this->assertSame('john@example.com', $identities[0]->email);
+ $this->assertSame('John Doe', $identities[0]->name);
+});
+
+test('should parse email with parenthesised name', function (): void {
+ $identities = (new EmailAddressParser('john@example.com (John Doe)'))->parse();
+
+ $this->assertCount(1, $identities);
+ $this->assertSame('john@example.com', $identities[0]->email);
+ $this->assertSame('John Doe', $identities[0]->name);
+});
+
+test('should strip original marker', function (): void {
+ $identities = (new EmailAddressParser('John Doe (original) '))->parse();
+
+ $this->assertCount(1, $identities);
+ $this->assertSame('john@example.com', $identities[0]->email);
+ $this->assertSame('John Doe', $identities[0]->name);
+});
+
+test('should collapse dotted email', function (): void {
+ $identities = (new EmailAddressParser('john . doe@example.com'))->parse();
+
+ $this->assertCount(1, $identities);
+ $this->assertSame('john.doe@example.com', $identities[0]->email);
+});
+
+test('should invalidate malformed email', function (): void {
+ $identities = (new EmailAddressParser('not-an-email'))->parse();
+
+ $this->assertCount(1, $identities);
+ $this->assertNull($identities[0]->email);
+});
+
+test('should skip short names', function (): void {
+ $identities = (new EmailAddressParser('Jo '))->parse();
+
+ $this->assertCount(1, $identities);
+ $this->assertSame('jo@example.com', $identities[0]->email);
+ $this->assertNull($identities[0]->name);
+});
+
+test('should skip name containing url', function (): void {
+ $identities = (new EmailAddressParser('http://example.com '))->parse();
+
+ $this->assertSame('john@example.com', $identities[0]->email);
+ $this->assertNull($identities[0]->name);
+});
diff --git a/tests/Unit/Services/Email/EmailContentParserTest.php b/tests/Unit/Services/Email/EmailContentParserTest.php
index d67d44b..a7abfd4 100644
--- a/tests/Unit/Services/Email/EmailContentParserTest.php
+++ b/tests/Unit/Services/Email/EmailContentParserTest.php
@@ -2,241 +2,217 @@
declare(strict_types=1);
-namespace Tests\Unit\Services\Email;
-
use App\Services\Email\EmailContentParser;
-use Tests\TestCase;
-
-class EmailContentParserTest extends TestCase
-{
- private EmailContentParser $parser;
-
- protected function setUp(): void
- {
- parent::setUp();
- $this->parser = $this->app->make(EmailContentParser::class);
- }
-
- public function test_should_parse_markdown(): void
- {
- $content = <<<'MARKDOWN'
- This is a paragraph.
-
- echo 'code';
-
- > Take that!
- MARKDOWN;
- $expected = <<<'HTML'
- This is a paragraph.
- echo 'code';
-
-
- Take that!
-
- HTML;
- $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_escape_html(): void
- {
- $content = 'Test of XSS injection';
- $this->assertEquals(
- 'Test of <strong>XSS</strong> <script>alert("xss")</script> injection
',
- mb_trim($this->parser->parse($content)),
- );
- }
-
- public function test_should_keep_line_breaks(): void
- {
- $content = <<<'MARKDOWN'
- This is a paragraph
- that spans on 2 lines:
+
+beforeEach(function (): void {
+ $this->parser = $this->app->make(EmailContentParser::class);
+});
+
+test('should parse markdown', function (): void {
+ $content = <<<'MARKDOWN'
+ This is a paragraph.
echo 'code';
- echo 'another code;
- MARKDOWN;
- $expected = <<<'HTML'
- This is a paragraph
- that spans on 2 lines:
- echo 'code';
- echo 'another code;
- HTML;
- $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_encode_html_entities(): void
- {
- $content = <<<'EMAIL'
- > and the test:
-
- test
-
- We use 2/3 vote for "a feature affecting the language itself".
- EMAIL;
- $expected = <<<'HTML'
-
- and the test: <hello>
-
- <foo></foo> test
- We use 2/3 vote for "a feature affecting the language itself".
- HTML;
- $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_support_php_opening_tag(): void
- {
- $content = <<<'EMAIL'
- one
-
-
- > ini_set();
- EMAIL;
- $expected = <<<'HTML'
- one
- <?php
- echo $foo;
- two <? hehe
-
- <?
- ini_set();
-
- HTML;
- $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_strip_mailing_list_signature(): void
- {
- $content = <<<'MARKDOWN'
- Hello
-
- ---
- PHP Internals - PHP Runtime Development Mailing List
- To unsubscribe, visit: http://www.php.net/unsub.php
- MARKDOWN;
- $this->assertEquals('Hello
', mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_strip_unindented_trailing_quotation_1(): void
- {
- $content = <<<'MARKDOWN'
- Hello Georges
-
- ---
-
- From: Georges Henry gh@example.com
- Sent: Friday, June 24, 2016 6:50:59 PM
- To: Pierre Lefroie
- Cc: PHP internals
- Subject: Re: [PHP-DEV] [RFC] Asynchronous Signal Handling
- MARKDOWN;
- $this->assertEquals('Hello Georges
', mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_strip_unindented_trailing_quotation_2(): void
- {
- $content = <<<'MARKDOWN'
- Hello Georges
-
- ________________________________
- From: Georges Henry gh@example.com
- Sent: Friday, June 24, 2016 6:50:59 PM
- To: Pierre Lefroie
- Cc: PHP internals
- Subject: Re: [PHP-DEV] [RFC] Asynchronous Signal Handling
- MARKDOWN;
- $this->assertEquals('Hello Georges
', mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_strip_trailing_line_breaks(): void
- {
- $content = <<<'MARKDOWN'
- Hello
-
-
- MARKDOWN;
- $this->assertEquals('Hello
', mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_linkify_links(): void
- {
- $content = 'Hello http://google.com';
- $expected = 'Hello http://google.com
';
- $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_detect_php_functions(): void
- {
- $content = 'Try to call preg_match() without parameters.';
- $expected = 'Try to call preg_match() without parameters.
';
- $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_detect_php_constants(): void
- {
- $content = 'Try to use PHP_INT_MAX and you will see.';
- $expected = 'Try to use PHP_INT_MAX and you will see.
';
- $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_handle_leading_blockquote(): void
- {
- $content = <<<'MARKDOWN'
- > But you still have to remember to use a
- > proper escaping function.
-
- I see no problem.
- MARKDOWN;
- $expected = <<<'HTML'
-
- But you still have to remember to use a
- proper escaping function.
-
- I see no problem.
- HTML;
- $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
- }
-
- public function test_should_strip_quote_headers(): void
- {
- $content = << wrote:\r\n
-
- > But you still have to remember to use
-
- Test with indented quotation header:
-
- > Test that the blockquote is not cut in half
- >
- > On 30/06/16 23:46, Thomas Bley wrote:
- >
- > > But you still have to remember to use
- > >
- >>> On 30/06/16 23:46, Thomas Bley wrote:
- >>> abc
-
- Trick: don't forget that On Wed, Stanislav wrote:
- MARKDOWN;
- $expected = <<<'HTML'
-
- But you still have to remember to use
-
- Test with indented quotation header:
-
- Test that the blockquote is not cut in half
-
- But you still have to remember to use
-
- abc
-
-
-
- Trick: don't forget that On Wed, Stanislav wrote:
- HTML;
- $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
- }
-}
+
+ > Take that!
+ MARKDOWN;
+ $expected = <<<'HTML'
+ This is a paragraph.
+ echo 'code';
+
+
+ Take that!
+
+ HTML;
+ $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
+});
+
+test('should escape html', function (): void {
+ $content = 'Test of XSS injection';
+ $this->assertEquals(
+ 'Test of <strong>XSS</strong> <script>alert("xss")</script> injection
',
+ mb_trim($this->parser->parse($content)),
+ );
+});
+
+test('should keep line breaks', function (): void {
+ $content = <<<'MARKDOWN'
+ This is a paragraph
+ that spans on 2 lines:
+
+ echo 'code';
+ echo 'another code;
+ MARKDOWN;
+ $expected = <<<'HTML'
+ This is a paragraph
+ that spans on 2 lines:
+ echo 'code';
+ echo 'another code;
+ HTML;
+ $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
+});
+
+test('should encode html entities', function (): void {
+ $content = <<<'EMAIL'
+ > and the test:
+
+ test
+
+ We use 2/3 vote for "a feature affecting the language itself".
+ EMAIL;
+ $expected = <<<'HTML'
+
+ and the test: <hello>
+
+ <foo></foo> test
+ We use 2/3 vote for "a feature affecting the language itself".
+ HTML;
+ $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
+});
+
+test('should support php opening tag', function (): void {
+ $content = <<<'EMAIL'
+ one
+
+
+ > ini_set();
+ EMAIL;
+ $expected = <<<'HTML'
+ one
+ <?php
+ echo $foo;
+ two <? hehe
+
+ <?
+ ini_set();
+
+ HTML;
+ $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
+});
+
+test('should strip mailing list signature', function (): void {
+ $content = <<<'MARKDOWN'
+ Hello
+
+ ---
+ PHP Internals - PHP Runtime Development Mailing List
+ To unsubscribe, visit: http://www.php.net/unsub.php
+ MARKDOWN;
+ $this->assertEquals('Hello
', mb_trim($this->parser->parse($content)));
+});
+
+test('should strip unindented trailing quotation 1', function (): void {
+ $content = <<<'MARKDOWN'
+ Hello Georges
+
+ ---
+
+ From: Georges Henry gh@example.com
+ Sent: Friday, June 24, 2016 6:50:59 PM
+ To: Pierre Lefroie
+ Cc: PHP internals
+ Subject: Re: [PHP-DEV] [RFC] Asynchronous Signal Handling
+ MARKDOWN;
+ $this->assertEquals('Hello Georges
', mb_trim($this->parser->parse($content)));
+});
+
+test('should strip unindented trailing quotation 2', function (): void {
+ $content = <<<'MARKDOWN'
+ Hello Georges
+
+ ________________________________
+ From: Georges Henry gh@example.com
+ Sent: Friday, June 24, 2016 6:50:59 PM
+ To: Pierre Lefroie
+ Cc: PHP internals
+ Subject: Re: [PHP-DEV] [RFC] Asynchronous Signal Handling
+ MARKDOWN;
+ $this->assertEquals('Hello Georges
', mb_trim($this->parser->parse($content)));
+});
+
+test('should strip trailing line breaks', function (): void {
+ $content = <<<'MARKDOWN'
+ Hello
+
+
+ MARKDOWN;
+ $this->assertEquals('Hello
', mb_trim($this->parser->parse($content)));
+});
+
+test('should linkify links', function (): void {
+ $content = 'Hello http://google.com';
+ $expected = 'Hello http://google.com
';
+ $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
+});
+
+test('should detect php functions', function (): void {
+ $content = 'Try to call preg_match() without parameters.';
+ $expected = 'Try to call preg_match() without parameters.
';
+ $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
+});
+
+test('should detect php constants', function (): void {
+ $content = 'Try to use PHP_INT_MAX and you will see.';
+ $expected = 'Try to use PHP_INT_MAX and you will see.
';
+ $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
+});
+
+test('should handle leading blockquote', function (): void {
+ $content = <<<'MARKDOWN'
+ > But you still have to remember to use a
+ > proper escaping function.
+
+ I see no problem.
+ MARKDOWN;
+ $expected = <<<'HTML'
+
+ But you still have to remember to use a
+ proper escaping function.
+
+ I see no problem.
+ HTML;
+ $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
+});
+
+test('should strip quote headers', function (): void {
+ $content = << wrote:\r\n
+
+ > But you still have to remember to use
+
+ Test with indented quotation header:
+
+ > Test that the blockquote is not cut in half
+ >
+ > On 30/06/16 23:46, Thomas Bley wrote:
+ >
+ > > But you still have to remember to use
+ > >
+ >>> On 30/06/16 23:46, Thomas Bley wrote:
+ >>> abc
+
+ Trick: don't forget that On Wed, Stanislav wrote:
+ MARKDOWN;
+ $expected = <<<'HTML'
+
+ But you still have to remember to use
+
+ Test with indented quotation header:
+
+ Test that the blockquote is not cut in half
+
+ But you still have to remember to use
+
+ abc
+
+
+
+ Trick: don't forget that On Wed, Stanislav wrote:
+ HTML;
+ $this->assertEquals($expected, mb_trim($this->parser->parse($content)));
+});
diff --git a/tests/Unit/Services/Email/EmailSubjectParserTest.php b/tests/Unit/Services/Email/EmailSubjectParserTest.php
index 80a6f80..9575583 100644
--- a/tests/Unit/Services/Email/EmailSubjectParserTest.php
+++ b/tests/Unit/Services/Email/EmailSubjectParserTest.php
@@ -2,60 +2,42 @@
declare(strict_types=1);
-namespace Tests\Unit\Services\Email;
-
use App\Services\Email\EmailSubjectParser;
-use Tests\TestCase;
-
-class EmailSubjectParserTest extends TestCase
-{
- private EmailSubjectParser $parser;
-
- protected function setUp(): void
- {
- parent::setUp();
- $this->parser = new EmailSubjectParser;
- }
-
- public function test_should_strip_php_dev_tag(): void
- {
- $this->assertSame('Hello world', $this->parser->sanitize('[PHP-DEV] Hello world'));
- }
-
- public function test_should_strip_re_prefix(): void
- {
- $this->assertSame('Hello world', $this->parser->sanitize('Re: Hello world'));
- }
-
- public function test_should_strip_re_prefix_case_insensitively(): void
- {
- $this->assertSame('Hello world', $this->parser->sanitize('RE: Hello world'));
- $this->assertSame('Hello world', $this->parser->sanitize('re: Hello world'));
- }
-
- public function test_should_strip_re_prefix_without_space(): void
- {
- $this->assertSame('Hello world', $this->parser->sanitize('Re:Hello world'));
- }
-
- public function test_should_strip_multiple_re_prefixes(): void
- {
- $this->assertSame('Hello world', $this->parser->sanitize('Re: Re: Re: Hello world'));
- }
-
- public function test_should_strip_php_dev_and_re_together(): void
- {
- $this->assertSame('Hello world', $this->parser->sanitize('Re: [PHP-DEV] Hello world'));
- $this->assertSame('Hello world', $this->parser->sanitize('[PHP-DEV] Re: Hello world'));
- }
-
- public function test_should_leave_normal_subject_untouched(): void
- {
- $this->assertSame('Hello world', $this->parser->sanitize('Hello world'));
- }
-
- public function test_should_not_strip_re_in_the_middle_of_subject(): void
- {
- $this->assertSame('Talking about Re: discussions', $this->parser->sanitize('Talking about Re: discussions'));
- }
-}
+
+beforeEach(function (): void {
+ $this->parser = new EmailSubjectParser;
+});
+
+test('should strip php dev tag', function (): void {
+ $this->assertSame('Hello world', $this->parser->sanitize('[PHP-DEV] Hello world'));
+});
+
+test('should strip re prefix', function (): void {
+ $this->assertSame('Hello world', $this->parser->sanitize('Re: Hello world'));
+});
+
+test('should strip re prefix case insensitively', function (): void {
+ $this->assertSame('Hello world', $this->parser->sanitize('RE: Hello world'));
+ $this->assertSame('Hello world', $this->parser->sanitize('re: Hello world'));
+});
+
+test('should strip re prefix without space', function (): void {
+ $this->assertSame('Hello world', $this->parser->sanitize('Re:Hello world'));
+});
+
+test('should strip multiple re prefixes', function (): void {
+ $this->assertSame('Hello world', $this->parser->sanitize('Re: Re: Re: Hello world'));
+});
+
+test('should strip php dev and re together', function (): void {
+ $this->assertSame('Hello world', $this->parser->sanitize('Re: [PHP-DEV] Hello world'));
+ $this->assertSame('Hello world', $this->parser->sanitize('[PHP-DEV] Re: Hello world'));
+});
+
+test('should leave normal subject untouched', function (): void {
+ $this->assertSame('Hello world', $this->parser->sanitize('Hello world'));
+});
+
+test('should not strip re in the middle of subject', function (): void {
+ $this->assertSame('Talking about Re: discussions', $this->parser->sanitize('Talking about Re: discussions'));
+});
diff --git a/tests/Unit/Services/Rss/RssBuilderTest.php b/tests/Unit/Services/Rss/RssBuilderTest.php
index af7300a..078834e 100644
--- a/tests/Unit/Services/Rss/RssBuilderTest.php
+++ b/tests/Unit/Services/Rss/RssBuilderTest.php
@@ -2,64 +2,55 @@
declare(strict_types=1);
-namespace Tests\Unit\Services\Rss;
-
use App\Models\Email;
use App\Services\Rss\RssBuilder;
use DateTimeImmutable;
use SimpleXMLElement;
-use Tests\TestCase;
-
-class RssBuilderTest extends TestCase
-{
- public function test_should_build_valid_rss_with_no_items(): void
- {
- $xml = (new RssBuilder('https://externals.io'))->build([]);
-
- $rss = new SimpleXMLElement($xml);
- $this->assertSame('rss', $rss->getName());
- $this->assertSame('2.0', (string) $rss['version']);
- $this->assertSame('#externals', (string) $rss->channel->title);
- $this->assertSame('https://externals.io', (string) $rss->channel->link);
- $this->assertCount(0, $rss->channel->item);
- }
-
- public function test_should_build_item_for_each_email(): void
- {
- $email = new Email([
- 'id' => '',
- 'number' => 42,
- 'subject' => 'Hello world',
- 'content' => 'Body
',
- 'date' => new DateTimeImmutable('2026-01-15 10:00:00'),
- ]);
-
- $xml = (new RssBuilder('https://externals.io'))->build([$email]);
-
- $rss = new SimpleXMLElement($xml);
- $this->assertCount(1, $rss->channel->item);
- $item = $rss->channel->item[0];
- $this->assertSame('Hello world', (string) $item->title);
- $this->assertSame('https://externals.io/message/42', (string) $item->link);
- $this->assertSame('Body
', (string) $item->description);
- $this->assertSame('', (string) $item->guid);
- $this->assertSame((new DateTimeImmutable('2026-01-15 10:00:00'))->format('r'), (string) $item->pubDate);
- }
-
- public function test_should_escape_special_characters_in_subject(): void
- {
- $email = new Email([
- 'id' => '',
- 'number' => 1,
- 'subject' => 'Subject with & "quotes"',
- 'content' => '',
- 'date' => new DateTimeImmutable('2026-01-01 00:00:00'),
- ]);
-
- $xml = (new RssBuilder('https://externals.io'))->build([$email]);
- // Round-trip through SimpleXMLElement: special chars must survive intact.
- $rss = new SimpleXMLElement($xml);
- $this->assertSame('Subject with & "quotes"', (string) $rss->channel->item[0]->title);
- }
-}
+test('should build valid rss with no items', function (): void {
+ $xml = (new RssBuilder('https://externals.io'))->build([]);
+
+ $rss = new SimpleXMLElement($xml);
+ $this->assertSame('rss', $rss->getName());
+ $this->assertSame('2.0', (string) $rss['version']);
+ $this->assertSame('#externals', (string) $rss->channel->title);
+ $this->assertSame('https://externals.io', (string) $rss->channel->link);
+ $this->assertCount(0, $rss->channel->item);
+});
+
+test('should build item for each email', function (): void {
+ $email = new Email([
+ 'id' => '',
+ 'number' => 42,
+ 'subject' => 'Hello world',
+ 'content' => 'Body
',
+ 'date' => new DateTimeImmutable('2026-01-15 10:00:00'),
+ ]);
+
+ $xml = (new RssBuilder('https://externals.io'))->build([$email]);
+
+ $rss = new SimpleXMLElement($xml);
+ $this->assertCount(1, $rss->channel->item);
+ $item = $rss->channel->item[0];
+ $this->assertSame('Hello world', (string) $item->title);
+ $this->assertSame('https://externals.io/message/42', (string) $item->link);
+ $this->assertSame('Body
', (string) $item->description);
+ $this->assertSame('', (string) $item->guid);
+ $this->assertSame((new DateTimeImmutable('2026-01-15 10:00:00'))->format('r'), (string) $item->pubDate);
+});
+
+test('should escape special characters in subject', function (): void {
+ $email = new Email([
+ 'id' => '',
+ 'number' => 1,
+ 'subject' => 'Subject with & "quotes"',
+ 'content' => '',
+ 'date' => new DateTimeImmutable('2026-01-01 00:00:00'),
+ ]);
+
+ $xml = (new RssBuilder('https://externals.io'))->build([$email]);
+
+ // Round-trip through SimpleXMLElement: special chars must survive intact.
+ $rss = new SimpleXMLElement($xml);
+ $this->assertSame('Subject with & "quotes"', (string) $rss->channel->item[0]->title);
+});
diff --git a/tests/Unit/Services/Rss/RssRfcBuilderTest.php b/tests/Unit/Services/Rss/RssRfcBuilderTest.php
index d9c2c25..0086984 100644
--- a/tests/Unit/Services/Rss/RssRfcBuilderTest.php
+++ b/tests/Unit/Services/Rss/RssRfcBuilderTest.php
@@ -2,54 +2,62 @@
declare(strict_types=1);
-namespace Tests\Unit\Services\Rss;
-
use App\Services\Rss\RssRfcBuilder;
+use App\Support\Email\ThreadSummary;
+use Carbon\CarbonImmutable;
use DateTimeImmutable;
use SimpleXMLElement;
-use Tests\TestCase;
-
-class RssRfcBuilderTest extends TestCase
-{
- public function test_should_build_valid_rss_with_no_threads(): void
- {
- $xml = (new RssRfcBuilder('https://externals.io'))->build([]);
-
- $rss = new SimpleXMLElement($xml);
- $this->assertSame('rss', $rss->getName());
- $this->assertSame('#externals - Latest RFC Threads', (string) $rss->channel->title);
- $this->assertSame('https://externals.io', (string) $rss->channel->link);
- $this->assertCount(0, $rss->channel->item);
- }
-
- public function test_should_build_item_for_each_thread(): void
- {
- $threads = [
- [
- 'number' => 100,
- 'subject' => '[RFC] My proposal',
- 'date' => '2026-02-10 12:34:56',
- ],
- [
- 'number' => 101,
- 'subject' => '[RFC] Another proposal',
- 'date' => '2026-02-11 09:00:00',
- ],
- ];
-
- $xml = (new RssRfcBuilder('https://externals.io'))->build($threads);
-
- $rss = new SimpleXMLElement($xml);
- $this->assertCount(2, $rss->channel->item);
-
- $first = $rss->channel->item[0];
- $this->assertSame('[RFC] My proposal', (string) $first->title);
- $this->assertSame('https://externals.io/message/100', (string) $first->link);
- $this->assertSame('[RFC] My proposal', (string) $first->description);
- $this->assertSame('100', (string) $first->guid);
- $this->assertSame((new DateTimeImmutable('2026-02-10 12:34:56'))->format('r'), (string) $first->pubDate);
-
- $second = $rss->channel->item[1];
- $this->assertSame('101', (string) $second->guid);
- }
-}
+
+test('should build valid rss with no threads', function (): void {
+ $xml = (new RssRfcBuilder('https://externals.io'))->build([]);
+
+ $rss = new SimpleXMLElement($xml);
+ $this->assertSame('rss', $rss->getName());
+ $this->assertSame('#externals - Latest RFC Threads', (string) $rss->channel->title);
+ $this->assertSame('https://externals.io', (string) $rss->channel->link);
+ $this->assertCount(0, $rss->channel->item);
+});
+
+test('should build item for each thread', function (): void {
+ $threads = [
+ new ThreadSummary(
+ number: 100,
+ subject: '[RFC] My proposal',
+ date: CarbonImmutable::parse('2026-02-10 12:34:56'),
+ fromName: null,
+ fromEmail: null,
+ emailCount: 1,
+ lastUpdate: CarbonImmutable::parse('2026-02-10 12:34:56'),
+ votes: 0,
+ isRead: false,
+ userVote: null,
+ ),
+ new ThreadSummary(
+ number: 101,
+ subject: '[RFC] Another proposal',
+ date: CarbonImmutable::parse('2026-02-11 09:00:00'),
+ fromName: null,
+ fromEmail: null,
+ emailCount: 1,
+ lastUpdate: CarbonImmutable::parse('2026-02-11 09:00:00'),
+ votes: 0,
+ isRead: false,
+ userVote: null,
+ ),
+ ];
+
+ $xml = (new RssRfcBuilder('https://externals.io'))->build($threads);
+
+ $rss = new SimpleXMLElement($xml);
+ $this->assertCount(2, $rss->channel->item);
+
+ $first = $rss->channel->item[0];
+ $this->assertSame('[RFC] My proposal', (string) $first->title);
+ $this->assertSame('https://externals.io/message/100', (string) $first->link);
+ $this->assertSame('[RFC] My proposal', (string) $first->description);
+ $this->assertSame('100', (string) $first->guid);
+ $this->assertSame((new DateTimeImmutable('2026-02-10 12:34:56'))->format('r'), (string) $first->pubDate);
+
+ $second = $rss->channel->item[1];
+ $this->assertSame('101', (string) $second->guid);
+});