From ef804b02c2737e333e53bd123abc7c875ad190c9 Mon Sep 17 00:00:00 2001 From: Jure Knezovic Date: Tue, 20 Oct 2026 15:24:18 +0200 Subject: [PATCH] Store reports on a configurable filesystem disk Reports, their chunks and their pages were written with the File facade to a hard-coded storage_path(). Where the filesystem is not shared between processes or does not survive a deploy (containers, serverless platforms, autoscaled instances), the report the "Generate" request wrote is not there for the redirect that follows, so the show page answers 404, and the chunks the queue worker writes are never seen by the web process. A new `reports.disk` config key names a disk from config/filesystems.php to store the reports on. Unset, a local disk rooted at storage/statamic/seopro/reports keeps the current layout, so existing reports and the existing tests stay as they are. The report's folder and path methods are relative to that disk now; `pagesFolder()` is new, `preparePath()` is gone (a disk creates directories on write). Co-Authored-By: Claude Fable 5.1 --- DOCUMENTATION.md | 14 ++++++++++ config/seo-pro.php | 1 + src/Reporting/Chunk.php | 6 ++-- src/Reporting/Page.php | 6 ++-- src/Reporting/Report.php | 59 ++++++++++++++++++++-------------------- tests/ReportTest.php | 27 ++++++++++++++++++ 6 files changed, 78 insertions(+), 35 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index de59ec94..60d78fbf 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -147,6 +147,20 @@ Reports will stick around until deleted, so you are free to compare reports to s You may generate a report through the Control Panel, or by running `php please seo-pro:generate-report`. +### Storage + +By default, reports are stored as YAML files in the `storage/statamic/seopro/reports` directory. Where the filesystem is not shared between processes or does not survive a deploy - containers, serverless platforms, autoscaled instances - a report written by one process is never seen by the next request, and generating one ends on a 404. Point reports at a filesystem disk instead: + +```php +// config/statamic/seo-pro.php + +'reports' => [ + 'disk' => 's3', +], +``` + +The disk is any disk from `config/filesystems.php`; reports are stored under `statamic/seopro/reports` on it. Reports generated before the switch stay on the local disk and are not listed until they are copied over. + ### Queuing Reports Depending on the size of your site, generating a report may take a while. To prevent request timeouts, you may enable queues, and the reports will be truly queued in the background. diff --git a/config/seo-pro.php b/config/seo-pro.php index 6415c6ad..88a6261b 100644 --- a/config/seo-pro.php +++ b/config/seo-pro.php @@ -50,6 +50,7 @@ ], 'reports' => [ + 'disk' => null, 'keep_recent' => 'all', 'queue_chunk_size' => 1000, 'title_length' => [ diff --git a/src/Reporting/Chunk.php b/src/Reporting/Chunk.php index 6424d34e..c0567c94 100644 --- a/src/Reporting/Chunk.php +++ b/src/Reporting/Chunk.php @@ -37,7 +37,7 @@ protected function yamlPath() public function save() { - File::put($this->yamlPath(), YAML::dump([ + File::disk(Report::disk())->put($this->yamlPath(), YAML::dump([ 'ids' => $this->contentIds, ])); @@ -61,7 +61,7 @@ protected function generate($ids) $this->createPage($content[$id] ?? Data::find($id))?->save(); } - File::delete($this->folderPath()); + File::disk(Report::disk())->delete($this->folderPath()); if ($this->wasLastChunk()) { $this->report->finalize(); @@ -70,7 +70,7 @@ protected function generate($ids) protected function wasLastChunk() { - return File::getFolders($this->report->chunksFolder())->isEmpty(); + return File::disk(Report::disk())->getFolders($this->report->chunksFolder())->isEmpty(); } protected function createPage($content) diff --git a/src/Reporting/Page.php b/src/Reporting/Page.php index b811ddb1..4c0cc666 100644 --- a/src/Reporting/Page.php +++ b/src/Reporting/Page.php @@ -146,7 +146,7 @@ public function save() 'results' => $this->results, ]; - File::put($this->path(), YAML::dump($data)); + File::disk(Report::disk())->put($this->path(), YAML::dump($data)); } protected function path() @@ -154,9 +154,9 @@ protected function path() $key = md5($this->id); $parts = array_slice(str_split($key, 2), 0, 2); - return storage_path(vsprintf('statamic/seopro/reports/%s/pages/%s/%s.yaml', [ + return vsprintf('statamic/seopro/reports/%s/pages/%s/%s.yaml', [ $this->report->id(), implode('/', $parts), $key, - ])); + ]); } public function model() diff --git a/src/Reporting/Report.php b/src/Reporting/Report.php index a781ad49..189fae09 100644 --- a/src/Reporting/Report.php +++ b/src/Reporting/Report.php @@ -7,9 +7,9 @@ use Illuminate\Contracts\Support\Jsonable; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Storage; use Statamic\Facades\Entry; use Statamic\Facades\File; -use Statamic\Facades\Folder; use Statamic\Facades\Term; use Statamic\Facades\YAML; @@ -112,8 +112,8 @@ public function generate() protected function hasRemainingChunks() { - return File::exists($this->chunksFolder()) - && File::getFolders($this->chunksFolder())->isNotEmpty(); + return File::disk(static::disk())->exists($this->chunksFolder()) + && File::disk(static::disk())->getFolders($this->chunksFolder())->isNotEmpty(); } protected function chunks() @@ -139,8 +139,8 @@ protected function chunks() public function finalize() { - if (File::exists($dir = $this->chunksFolder())) { - File::delete($dir); + if (File::disk(static::disk())->exists($dir = $this->chunksFolder())) { + File::disk(static::disk())->delete($dir); } $this @@ -284,12 +284,12 @@ public function withPages($preferCache = false) return $this; } - $dir = storage_path(sprintf('statamic/seopro/reports/%s/pages', $this->id)); + $dir = sprintf('statamic/seopro/reports/%s/pages', $this->id); - $files = Folder::getFilesRecursively($dir); + $files = File::disk(static::disk())->getFilesRecursively($dir); $this->pages = collect($files) - ->map(fn ($file) => YAML::parse(File::get($file))) + ->map(fn ($file) => YAML::parse(File::disk(static::disk())->get($file))) ->map(fn ($yaml) => (new Page($yaml['id'], $yaml['data'], $this))->setResults($yaml['results'])) ->sortBy ->url() @@ -326,7 +326,7 @@ public function generateIfNecessary(): self public static function all() { - $folders = collect(Folder::getFolders(static::preparePath())); + $folders = collect(File::disk(static::disk())->getFolders('statamic/seopro/reports')); if ($folders->isEmpty()) { return $folders; @@ -369,7 +369,7 @@ public function fresh() public function save() { - File::put($this->path(), YAML::dump($this->raw = [ + File::disk(static::disk())->put($this->path(), YAML::dump($this->raw = [ 'date' => $this->date ?? now()->timestamp, 'status' => $this->status(), 'score' => $this->score(), @@ -385,34 +385,46 @@ public function save() public function delete() { - File::delete($this->parentFolder()); + File::disk(static::disk())->delete($this->parentFolder()); return $this->clearCaches(); } + /** + * The disk for File::disk(): the one configured in `reports.disk`, or else the local + * storage directory. Reports live under statamic/seopro/reports on it. + */ + public static function disk() + { + return config('statamic.seo-pro.reports.disk') ?: Storage::build([ + 'driver' => 'local', + 'root' => storage_path(), + ]); + } + public function parentFolder() { - return storage_path('statamic/seopro/reports/'.$this->id); + return 'statamic/seopro/reports/'.$this->id; } public function chunksFolder() { - return storage_path('statamic/seopro/reports/'.$this->id.'/chunks'); + return 'statamic/seopro/reports/'.$this->id.'/chunks'; } public function path() { - return storage_path('statamic/seopro/reports/'.$this->id.'/report.yaml'); + return 'statamic/seopro/reports/'.$this->id.'/report.yaml'; } public function exists() { - return File::exists($this->path()); + return File::disk(static::disk())->exists($this->path()); } public function load() { - $this->raw = YAML::parse(File::get($this->path())); + $this->raw = YAML::parse(File::disk(static::disk())->get($this->path())); $this->date = $this->raw['date']; $this->results = $this->raw['results']; @@ -499,17 +511,6 @@ protected function generateScore() return $this; } - public static function preparePath($path = null) - { - $path = collect([storage_path('statamic/seopro/reports'), $path])->filter()->implode('/'); - - if (! File::exists($path)) { - File::makeDirectory($path, 0755, true); - } - - return $path; - } - protected function allContent() { $content = collect() @@ -562,7 +563,7 @@ public static function deleteOldReports() return false; } - $reports = collect(app('files')->directories(storage_path('statamic/seopro/reports'))); + $reports = collect(File::disk(static::disk())->getFolders('statamic/seopro/reports')); $keepReports = $reports ->keyBy(fn ($path) => pathinfo($path)['basename']) @@ -571,7 +572,7 @@ public static function deleteOldReports() $reports ->reject(fn ($path) => $keepReports->contains($path)) - ->each(fn ($path) => app('files')->deleteDirectory($path)); + ->each(fn ($path) => File::disk(static::disk())->delete($path)); return true; } diff --git a/tests/ReportTest.php b/tests/ReportTest.php index df384a9c..c5e5cf46 100644 --- a/tests/ReportTest.php +++ b/tests/ReportTest.php @@ -3,6 +3,7 @@ namespace Tests; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Blink; use Statamic\Facades\Config; @@ -68,6 +69,32 @@ public function it_increments_report_folder_numbers() $this->assertFileExists($this->reportsPath('3')); } + #[Test] + public function it_stores_reports_on_the_configured_disk() + { + config(['statamic.seo-pro.reports.disk' => 'reports']); + Storage::fake('reports'); + + $this->generateEntries(3); + + Report::create()->save()->generate(); + + Storage::disk('reports')->assertExists('statamic/seopro/reports/1/report.yaml'); + $this->assertCount(3, Storage::disk('reports')->allFiles('statamic/seopro/reports/1/pages')); + $this->assertCount(0, Storage::disk('reports')->directories('statamic/seopro/reports/1/chunks')); + $this->assertFileDoesNotExist($this->reportsPath()); + + $this->assertCount(1, Report::all()); + $this->assertSame(1, Report::find(1)->id()); + $this->assertSame(3, Report::find(1)->withPages()->pagesCrawled()); + $this->assertSame(2, Report::nextId()); + + Report::find(1)->delete(); + + Storage::disk('reports')->assertMissing('statamic/seopro/reports/1/report.yaml'); + $this->assertCount(0, Report::all()); + } + #[Test] public function it_can_generate_a_report() {