From a0a1c08ecefe64f6588e6f26c25a5c5771c2b334 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Fri, 2 Jan 2026 13:58:06 +0000 Subject: [PATCH 01/25] added initial entries model --- CHANGELOG.md | 7 +- LICENSE | 2 +- .../Resources/Entries/EntryResource.php | 58 +++++++++++++ .../Resources/Entries/Pages/CreateEntry.php | 11 +++ .../Resources/Entries/Pages/EditEntry.php | 21 +++++ .../Resources/Entries/Pages/ListEntries.php | 19 ++++ .../Resources/Entries/Pages/ViewEntry.php | 19 ++++ .../Resources/Entries/Schemas/EntryForm.php | 39 +++++++++ .../Entries/Schemas/EntryInfolist.php | 38 ++++++++ .../Resources/Entries/Tables/EntriesTable.php | 52 +++++++++++ app/Models/Entry.php | 18 ++++ app/Providers/Filament/AdminPanelProvider.php | 2 +- ...2026_01_02_134204_create_entries_table.php | 34 ++++++++ docs/decisions/002-AGPLv3-and-filament.md | 1 - ...003-initial-model-and-filament-resource.md | 86 +++++++++++++++++++ tests/Pest.php | 2 +- tests/TestCase.php | 12 ++- tests/Unit/EntryModelTest.php | 19 ++++ tests/Unit/EntryResourceTest.php | 53 ++++++++++++ 19 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 app/Filament/Resources/Entries/EntryResource.php create mode 100644 app/Filament/Resources/Entries/Pages/CreateEntry.php create mode 100644 app/Filament/Resources/Entries/Pages/EditEntry.php create mode 100644 app/Filament/Resources/Entries/Pages/ListEntries.php create mode 100644 app/Filament/Resources/Entries/Pages/ViewEntry.php create mode 100644 app/Filament/Resources/Entries/Schemas/EntryForm.php create mode 100644 app/Filament/Resources/Entries/Schemas/EntryInfolist.php create mode 100644 app/Filament/Resources/Entries/Tables/EntriesTable.php create mode 100644 app/Models/Entry.php create mode 100644 database/migrations/2026_01_02_134204_create_entries_table.php create mode 100644 docs/decisions/003-initial-model-and-filament-resource.md create mode 100644 tests/Unit/EntryModelTest.php create mode 100644 tests/Unit/EntryResourceTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d22dff2..76dc870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,4 +4,9 @@ added: laravel 12 -added: AGPLv3 \ No newline at end of file +added: AGPLv3 + +## 2026-01-02 + +added initial model and filament resource + diff --git a/LICENSE b/LICENSE index 8c8c48f..aaddaf1 100644 --- a/LICENSE +++ b/LICENSE @@ -219,7 +219,7 @@ If you develop a new program, and you want it to be of the greatest possible use To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - test + share-lt Copyright (C) 2026 jon This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/app/Filament/Resources/Entries/EntryResource.php b/app/Filament/Resources/Entries/EntryResource.php new file mode 100644 index 0000000..9b15701 --- /dev/null +++ b/app/Filament/Resources/Entries/EntryResource.php @@ -0,0 +1,58 @@ + ListEntries::route('/'), + 'create' => CreateEntry::route('/create'), + 'view' => ViewEntry::route('/{record}'), + 'edit' => EditEntry::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Entries/Pages/CreateEntry.php b/app/Filament/Resources/Entries/Pages/CreateEntry.php new file mode 100644 index 0000000..222b29b --- /dev/null +++ b/app/Filament/Resources/Entries/Pages/CreateEntry.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('title') + ->required() + ->reactive() + ->afterStateUpdated(function ($state, $set): void { + $set('slug', Str::slug((string) $state)); + }), + TextInput::make('slug') + ->required() + ->disabled(), + Textarea::make('description') + ->columnSpanFull(), + Toggle::make('is_published') + ->required(), + Toggle::make('is_featured') + ->required(), + DatePicker::make('published_at'), + RichEditor::make('content') + ->columnSpanFull(), + ]); + } +} diff --git a/app/Filament/Resources/Entries/Schemas/EntryInfolist.php b/app/Filament/Resources/Entries/Schemas/EntryInfolist.php new file mode 100644 index 0000000..6a5d87d --- /dev/null +++ b/app/Filament/Resources/Entries/Schemas/EntryInfolist.php @@ -0,0 +1,38 @@ +components([ + TextEntry::make('title'), + TextEntry::make('slug'), + TextEntry::make('description') + ->placeholder('-') + ->columnSpanFull(), + IconEntry::make('is_published') + ->boolean(), + IconEntry::make('is_featured') + ->boolean(), + TextEntry::make('published_at') + ->date() + ->placeholder('-'), + TextEntry::make('content') + ->placeholder('-') + ->columnSpanFull(), + TextEntry::make('created_at') + ->dateTime() + ->placeholder('-'), + TextEntry::make('updated_at') + ->dateTime() + ->placeholder('-'), + ]); + } +} diff --git a/app/Filament/Resources/Entries/Tables/EntriesTable.php b/app/Filament/Resources/Entries/Tables/EntriesTable.php new file mode 100644 index 0000000..57035c0 --- /dev/null +++ b/app/Filament/Resources/Entries/Tables/EntriesTable.php @@ -0,0 +1,52 @@ +columns([ + TextColumn::make('title') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + IconColumn::make('is_published') + ->boolean(), + IconColumn::make('is_featured') + ->boolean(), + TextColumn::make('published_at') + ->date() + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Models/Entry.php b/app/Models/Entry.php new file mode 100644 index 0000000..f9b7c1d --- /dev/null +++ b/app/Models/Entry.php @@ -0,0 +1,18 @@ +path('admin') ->login() ->colors([ - 'primary' => Color::Amber, + 'primary' => Color::Blue, ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') diff --git a/database/migrations/2026_01_02_134204_create_entries_table.php b/database/migrations/2026_01_02_134204_create_entries_table.php new file mode 100644 index 0000000..337002b --- /dev/null +++ b/database/migrations/2026_01_02_134204_create_entries_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->boolean('is_published')->default(false); + $table->boolean('is_featured')->default(false); + $table->date('published_at')->nullable(); + $table->mediumText('content')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entries'); + } +}; diff --git a/docs/decisions/002-AGPLv3-and-filament.md b/docs/decisions/002-AGPLv3-and-filament.md index d19b605..7bed142 100644 --- a/docs/decisions/002-AGPLv3-and-filament.md +++ b/docs/decisions/002-AGPLv3-and-filament.md @@ -12,4 +12,3 @@ It is a way to create apps that have all the features of an advanced app with ma There are so many things to do, we don't have the time to re-write or re-invent what is already achieved by filament and likely, better than we could create ourselves or likely dream of. - diff --git a/docs/decisions/003-initial-model-and-filament-resource.md b/docs/decisions/003-initial-model-and-filament-resource.md new file mode 100644 index 0000000..90e58bd --- /dev/null +++ b/docs/decisions/003-initial-model-and-filament-resource.md @@ -0,0 +1,86 @@ +# 2026-01-02 + +## Entry model + +```bash +php artisan make:model -m Entry +``` + +creates skeleton files for a model and migration + +we edit the migration to hold the fields we desire to be present in each record + +just the minimal are added for now, paying attention to the amount of data that these may need to hold + +* string (varchar): ~255 chars default. +* text: ~65 KB. +* mediumText: ~16 MB. + +```bash +php artisan migrate +``` + +runs the migration to create the table + +## Entry filament resource + +```bash +❯ php artisan filament:resource Entry + + The "title attribute" is used to label each record in the UI. + + You can leave this blank if records do not have a title. + + ┌ What is the title attribute for this model? ─────────────────┐ + │ title │ + └──────────────────────────────────────────────────────────────┘ + + ┌ Would you like to generate a read-only view page for the resource? ┐ + │ Yes │ + └────────────────────────────────────────────────────────────────────┘ + + ┌ Should the configuration be generated from the current database columns? ┐ + │ Yes │ + └──────────────────────────────────────────────────────────────────────────┘ + + INFO Filament resource [App\Filament\Resources\Entries\EntryResource] created successfully. +``` + +There is more work to do on this model but for now, a new resource is added to the admin panel by which records can be added, edited, listed. + +Full CRUD is already possible. This is amazing if you think how long this would have taken to create all this manually. + + + +### slugify + +```php + TextInput::make('title') + ->required() + ->reactive() + ->afterStateUpdated(function ($state, $set): void { + $set('slug', Str::slug((string) $state)); + }), + TextInput::make('slug') + ->required(), +``` + +adding reactive and afterStateUpdted to title automatically creates a safe slug + +I class things like this creature comforts of the framework and it shows how simple it can be to make fields active and updated by previous entries + +For the user, this reduces error and confusion over what a url should be + +### rich editor + +just changing the TextInput for content + +```php + RichEditor::make('content') + ->columnSpanFull(), +``` + +to `RichEditr` gives us a 'tiptap' rich text editor + +already, this is feeling more like a mini CMS, with relatively little effort + diff --git a/tests/Pest.php b/tests/Pest.php index 40d096b..b3a25b7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,7 +13,7 @@ pest()->extend(Tests\TestCase::class) ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) - ->in('Feature'); + ->in('Feature', 'Unit'); /* |-------------------------------------------------------------------------- diff --git a/tests/TestCase.php b/tests/TestCase.php index fe1ffc2..65c00a0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,5 +6,15 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + /** + * Creates the application. + */ + public function createApplication(): \Illuminate\Foundation\Application + { + $app = require __DIR__ . '/../bootstrap/app.php'; + + $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + + return $app; + } } diff --git a/tests/Unit/EntryModelTest.php b/tests/Unit/EntryModelTest.php new file mode 100644 index 0000000..e339278 --- /dev/null +++ b/tests/Unit/EntryModelTest.php @@ -0,0 +1,19 @@ +getFillable())->toEqual($expected); +}); diff --git a/tests/Unit/EntryResourceTest.php b/tests/Unit/EntryResourceTest.php new file mode 100644 index 0000000..3757f62 --- /dev/null +++ b/tests/Unit/EntryResourceTest.php @@ -0,0 +1,53 @@ +getDefaultProperties(); + + expect($defaults['model'] ?? null)->toBe(Entry::class); + expect($defaults['recordTitleAttribute'] ?? null)->toBe('title'); +}); + +it('defines the expected pages', function () { + $pages = EntryResource::getPages(); + + expect(array_keys($pages))->toEqual(['index', 'create', 'view', 'edit']); +}); + +it('accepts new record when entered via the form schema', function () { + + $data = [ + 'title' => 'Test Entry', + 'slug' => 'test-entry', + 'description' => 'This is a test entry.', + 'is_published' => true, + 'is_featured' => false, + 'published_at' => now()->toDateString(), + 'content' => '

This is the content of the test entry.

', + ]; + + $entry = new Entry(); + $entry->fill($data); + + expect($entry->slug)->toBe('test-entry'); +}); + +it('deletes a record correctly', function () { + $entry = Entry::create([ + 'title' => 'Test Entry to Delete', + 'slug' => 'test-entry-to-delete', + 'description' => 'This is a test entry.', + 'is_published' => false, + 'is_featured' => false, + 'published_at' => null, + 'content' => '

This is the content of the test entry.

', + ]); + + $entryId = $entry->id; + $entry->delete(); + + $deletedEntry = Entry::find($entryId); + expect($deletedEntry)->toBeNull(); +}); \ No newline at end of file From 5ea0ddce23051ac066aee57c36f2cd19041565fe Mon Sep 17 00:00:00 2001 From: jon brookes Date: Fri, 2 Jan 2026 16:56:48 +0000 Subject: [PATCH 02/25] feat: integrate Spatie Media Library and update configuration - Added Spatie Media Library dependencies to composer.json - Created media table migration for media management - Added media library configuration file - Updated Entry model to support media handling - Updated .gitignore to exclude Vite files - Added basic logging to app.js --- .gitignore | 1 + app/Models/Entry.php | 26 +- composer.json | 4 +- composer.lock | 507 +++++++++++++++++- config/media-library.php | 303 +++++++++++ .../2026_01_02_160151_create_media_table.php | 32 ++ docs/decisions/004-spatie-media-library | 10 + resources/js/app.js | 1 + 8 files changed, 874 insertions(+), 10 deletions(-) create mode 100644 config/media-library.php create mode 100644 database/migrations/2026_01_02_160151_create_media_table.php create mode 100644 docs/decisions/004-spatie-media-library diff --git a/.gitignore b/.gitignore index c7cf1fa..3a324ad 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ yarn-error.log /.nova /.vscode /.zed +.vite diff --git a/app/Models/Entry.php b/app/Models/Entry.php index f9b7c1d..d0036af 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -2,10 +2,19 @@ namespace App\Models; +use Filament\Forms\Components\RichEditor\FileAttachmentProviders\SpatieMediaLibraryFileAttachmentProvider; +use Filament\Forms\Components\RichEditor\Models\Concerns\InteractsWithRichContent; +use Filament\Forms\Components\RichEditor\Models\Contracts\HasRichContent; use Illuminate\Database\Eloquent\Model; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; + +class Entry extends Model implements HasRichContent, HasMedia -class Entry extends Model { + + use InteractsWithMedia, InteractsWithRichContent; + protected $fillable = [ 'title', 'slug', @@ -15,4 +24,19 @@ class Entry extends Model 'published_at', 'content', ]; + + + /** + * Set up rich content configuration for media library integration + */ + public function setUpRichContent(): void + { + $this->registerRichContent('content') + ->fileAttachmentProvider( + SpatieMediaLibraryFileAttachmentProvider::make() + ->collection('content-attachments') + ->preserveFilenames() + ); + } + } diff --git a/composer.json b/composer.json index f49e35f..4745dfb 100644 --- a/composer.json +++ b/composer.json @@ -8,10 +8,12 @@ "require": { "php": "^8.2", "filament/filament": "^4.0", + "filament/spatie-laravel-media-library-plugin": "^4.0", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", - "livewire/flux": "^2.9.0" + "livewire/flux": "^2.9.0", + "spatie/laravel-medialibrary": "^11.17" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index 1b34af6..010c9b8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ed91eaf8381afba35eea6bfdd94e4e18", + "content-hash": "bec347dcc3a450fc682920a766cbe019", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -565,6 +565,83 @@ ], "time": "2024-07-16T11:13:48+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "danharrin/date-format-converter", "version": "v0.3.1", @@ -1432,6 +1509,43 @@ }, "time": "2025-12-30T13:02:44+00:00" }, + { + "name": "filament/spatie-laravel-media-library-plugin", + "version": "v4.4.0", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git", + "reference": "73748df28a9c2e8c34d2c02f9314c330602e1830" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/spatie-laravel-media-library-plugin/zipball/73748df28a9c2e8c34d2c02f9314c330602e1830", + "reference": "73748df28a9c2e8c34d2c02f9314c330602e1830", + "shasum": "" + }, + "require": { + "filament/support": "self.version", + "php": "^8.2", + "spatie/laravel-medialibrary": "^11.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Filament\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Filament support for `spatie/laravel-medialibrary`.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-12-09T09:54:02+00:00" + }, { "name": "filament/support", "version": "v4.4.0", @@ -3535,6 +3649,84 @@ ], "time": "2025-12-19T02:00:29+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-12-10T09:58:31+00:00" + }, { "name": "masterminds/html5", "version": "2.10.0", @@ -3604,16 +3796,16 @@ }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -3631,7 +3823,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -3691,7 +3883,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -3703,7 +3895,7 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "nesbot/carbon", @@ -5382,6 +5574,134 @@ ], "time": "2022-12-17T21:53:22+00:00" }, + { + "name": "spatie/image", + "version": "3.8.7", + "source": { + "type": "git", + "url": "https://github.com/spatie/image.git", + "reference": "4d35db207c4b317bc221d02ab7ba94aa78b44c24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/image/zipball/4d35db207c4b317bc221d02ab7ba94aa78b44c24", + "reference": "4d35db207c4b317bc221d02ab7ba94aa78b44c24", + "shasum": "" + }, + "require": { + "ext-exif": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": "^8.2", + "spatie/image-optimizer": "^1.7.5", + "spatie/temporary-directory": "^2.2", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-imagick": "*", + "laravel/sail": "^1.34", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^1.10.50", + "spatie/pest-plugin-snapshots": "^2.1", + "spatie/pixelmatch-php": "^1.0", + "spatie/ray": "^1.40.1", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Manipulate images with an expressive API", + "homepage": "https://github.com/spatie/image", + "keywords": [ + "image", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/image/tree/3.8.7" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-11-24T15:10:50+00:00" + }, + { + "name": "spatie/image-optimizer", + "version": "1.8.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/image-optimizer.git", + "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/image-optimizer/zipball/2ad9ac7c19501739183359ae64ea6c15869c23d9", + "reference": "2ad9ac7c19501739183359ae64ea6c15869c23d9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.3|^8.0", + "psr/log": "^1.0 | ^2.0 | ^3.0", + "symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "pestphp/pest": "^1.21|^2.0|^3.0|^4.0", + "phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0", + "symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\ImageOptimizer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily optimize images using PHP", + "homepage": "https://github.com/spatie/image-optimizer", + "keywords": [ + "image-optimizer", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/image-optimizer/issues", + "source": "https://github.com/spatie/image-optimizer/tree/1.8.1" + }, + "time": "2025-11-26T10:57:19+00:00" + }, { "name": "spatie/invade", "version": "2.1.0", @@ -5441,6 +5761,116 @@ ], "time": "2024-05-17T09:06:10+00:00" }, + { + "name": "spatie/laravel-medialibrary", + "version": "11.17.7", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-medialibrary.git", + "reference": "237f34f70ae97523c1a99cad7176e229b8d6f0b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/237f34f70ae97523c1a99cad7176e229b8d6f0b6", + "reference": "237f34f70ae97523c1a99cad7176e229b8d6f0b6", + "shasum": "" + }, + "require": { + "composer/semver": "^3.4", + "ext-exif": "*", + "ext-fileinfo": "*", + "ext-json": "*", + "illuminate/bus": "^10.2|^11.0|^12.0", + "illuminate/conditionable": "^10.2|^11.0|^12.0", + "illuminate/console": "^10.2|^11.0|^12.0", + "illuminate/database": "^10.2|^11.0|^12.0", + "illuminate/pipeline": "^10.2|^11.0|^12.0", + "illuminate/support": "^10.2|^11.0|^12.0", + "maennchen/zipstream-php": "^3.1", + "php": "^8.2", + "spatie/image": "^3.3.2", + "spatie/laravel-package-tools": "^1.16.1", + "spatie/temporary-directory": "^2.2", + "symfony/console": "^6.4.1|^7.0|^8.0" + }, + "conflict": { + "php-ffmpeg/php-ffmpeg": "<0.6.1" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.293.10", + "ext-imagick": "*", + "ext-pdo_sqlite": "*", + "ext-zip": "*", + "guzzlehttp/guzzle": "^7.8.1", + "larastan/larastan": "^2.7|^3.0", + "league/flysystem-aws-s3-v3": "^3.22", + "mockery/mockery": "^1.6.7", + "orchestra/testbench": "^8.36|^9.15|^10.8", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/extension-installer": "^1.3.1", + "spatie/laravel-ray": "^1.33", + "spatie/pdf-to-image": "^2.2|^3.0", + "spatie/pest-expectations": "^1.13", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "suggest": { + "league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage", + "php-ffmpeg/php-ffmpeg": "Required for generating video thumbnails", + "spatie/pdf-to-image": "Required for generating thumbnails of PDFs and SVGs" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\MediaLibrary\\MediaLibraryServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\MediaLibrary\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Associate files with Eloquent models", + "homepage": "https://github.com/spatie/laravel-medialibrary", + "keywords": [ + "cms", + "conversion", + "downloads", + "images", + "laravel", + "laravel-medialibrary", + "media", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-medialibrary/issues", + "source": "https://github.com/spatie/laravel-medialibrary/tree/11.17.7" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-12-15T08:51:55+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.92.7", @@ -5567,6 +5997,67 @@ ], "time": "2025-02-21T14:16:57+00:00" }, + { + "name": "spatie/temporary-directory", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/580eddfe9a0a41a902cac6eeb8f066b42e65a32b", + "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\TemporaryDirectory\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", + "keywords": [ + "php", + "spatie", + "temporary-directory" + ], + "support": { + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.3.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-01-13T13:04:43+00:00" + }, { "name": "symfony/clock", "version": "v7.4.0", diff --git a/config/media-library.php b/config/media-library.php new file mode 100644 index 0000000..33d558a --- /dev/null +++ b/config/media-library.php @@ -0,0 +1,303 @@ + env('MEDIA_DISK', 'public'), + + /* + * The maximum file size of an item in bytes. + * Adding a larger file will result in an exception. + */ + 'max_file_size' => 1024 * 1024 * 10, // 10MB + + /* + * This queue connection will be used to generate derived and responsive images. + * Leave empty to use the default queue connection. + */ + 'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'), + + /* + * This queue will be used to generate derived and responsive images. + * Leave empty to use the default queue. + */ + 'queue_name' => env('MEDIA_QUEUE', ''), + + /* + * By default all conversions will be performed on a queue. + */ + 'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', true), + + /* + * Should database transactions be run after database commits? + */ + 'queue_conversions_after_database_commit' => env('QUEUE_CONVERSIONS_AFTER_DB_COMMIT', true), + + /* + * The fully qualified class name of the media model. + */ + 'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class, + + /* + * The fully qualified class name of the media observer. + */ + 'media_observer' => Spatie\MediaLibrary\MediaCollections\Models\Observers\MediaObserver::class, + + /* + * When enabled, media collections will be serialised using the default + * laravel model serialization behaviour. + * + * Keep this option disabled if using Media Library Pro components (https://medialibrary.pro) + */ + 'use_default_collection_serialization' => false, + + /* + * The fully qualified class name of the model used for temporary uploads. + * + * This model is only used in Media Library Pro (https://medialibrary.pro) + */ + 'temporary_upload_model' => Spatie\MediaLibraryPro\Models\TemporaryUpload::class, + + /* + * When enabled, Media Library Pro will only process temporary uploads that were uploaded + * in the same session. You can opt to disable this for stateless usage of + * the pro components. + */ + 'enable_temporary_uploads_session_affinity' => true, + + /* + * When enabled, Media Library pro will generate thumbnails for uploaded file. + */ + 'generate_thumbnails_for_temporary_uploads' => true, + + /* + * This is the class that is responsible for naming generated files. + */ + 'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class, + + /* + * The class that contains the strategy for determining a media file's path. + */ + 'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class, + + /* + * The class that contains the strategy for determining how to remove files. + */ + 'file_remover_class' => Spatie\MediaLibrary\Support\FileRemover\DefaultFileRemover::class, + + /* + * Here you can specify which path generator should be used for the given class. + */ + 'custom_path_generators' => [ + // Model::class => PathGenerator::class + // or + // 'model_morph_alias' => PathGenerator::class + ], + + /* + * When urls to files get generated, this class will be called. Use the default + * if your files are stored locally above the site root or on s3. + */ + 'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class, + + /* + * Moves media on updating to keep path consistent. Enable it only with a custom + * PathGenerator that uses, for example, the media UUID. + */ + 'moves_media_on_update' => false, + + /* + * Whether to activate versioning when urls to files get generated. + * When activated, this attaches a ?v=xx query string to the URL. + */ + 'version_urls' => false, + + /* + * The media library will try to optimize all converted images by removing + * metadata and applying a little bit of compression. These are + * the optimizers that will be used by default. + */ + 'image_optimizers' => [ + Spatie\ImageOptimizer\Optimizers\Jpegoptim::class => [ + '-m85', // set maximum quality to 85% + '--force', // ensure that progressive generation is always done also if a little bigger + '--strip-all', // this strips out all text information such as comments and EXIF data + '--all-progressive', // this will make sure the resulting image is a progressive one + ], + Spatie\ImageOptimizer\Optimizers\Pngquant::class => [ + '--force', // required parameter for this package + ], + Spatie\ImageOptimizer\Optimizers\Optipng::class => [ + '-i0', // this will result in a non-interlaced, progressive scanned image + '-o2', // this set the optimization level to two (multiple IDAT compression trials) + '-quiet', // required parameter for this package + ], + Spatie\ImageOptimizer\Optimizers\Svgo::class => [ + '--disable=cleanupIDs', // disabling because it is known to cause troubles + ], + Spatie\ImageOptimizer\Optimizers\Gifsicle::class => [ + '-b', // required parameter for this package + '-O3', // this produces the slowest but best results + ], + Spatie\ImageOptimizer\Optimizers\Cwebp::class => [ + '-m 6', // for the slowest compression method in order to get the best compression. + '-pass 10', // for maximizing the amount of analysis pass. + '-mt', // multithreading for some speed improvements. + '-q 90', // quality factor that brings the least noticeable changes. + ], + Spatie\ImageOptimizer\Optimizers\Avifenc::class => [ + '-a cq-level=23', // constant quality level, lower values mean better quality and greater file size (0-63). + '-j all', // number of jobs (worker threads, "all" uses all available cores). + '--min 0', // min quantizer for color (0-63). + '--max 63', // max quantizer for color (0-63). + '--minalpha 0', // min quantizer for alpha (0-63). + '--maxalpha 63', // max quantizer for alpha (0-63). + '-a end-usage=q', // rate control mode set to Constant Quality mode. + '-a tune=ssim', // SSIM as tune the encoder for distortion metric. + ], + ], + + /* + * These generators will be used to create an image of media files. + */ + 'image_generators' => [ + Spatie\MediaLibrary\Conversions\ImageGenerators\Image::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Webp::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Avif::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Pdf::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Svg::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Video::class, + ], + + /* + * The path where to store temporary files while performing image conversions. + * If set to null, storage_path('media-library/temp') will be used. + */ + 'temporary_directory_path' => null, + + /* + * The engine that should perform the image conversions. + * Should be either `gd` or `imagick`. + */ + 'image_driver' => env('IMAGE_DRIVER', 'gd'), + + /* + * FFMPEG & FFProbe binaries paths, only used if you try to generate video + * thumbnails and have installed the php-ffmpeg/php-ffmpeg composer + * dependency. + */ + 'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'), + 'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'), + + /* + * The timeout (in seconds) that will be used when generating video + * thumbnails via FFMPEG. + */ + 'ffmpeg_timeout' => env('FFMPEG_TIMEOUT', 900), + + /* + * The number of threads that FFMPEG should use. 0 means that FFMPEG + * may decide itself. + */ + 'ffmpeg_threads' => env('FFMPEG_THREADS', 0), + + /* + * Here you can override the class names of the jobs used by this package. Make sure + * your custom jobs extend the ones provided by the package. + */ + 'jobs' => [ + 'perform_conversions' => Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob::class, + 'generate_responsive_images' => Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob::class, + ], + + /* + * When using the addMediaFromUrl method you may want to replace the default downloader. + * This is particularly useful when the url of the image is behind a firewall and + * need to add additional flags, possibly using curl. + */ + 'media_downloader' => Spatie\MediaLibrary\Downloaders\DefaultDownloader::class, + + /* + * When using the addMediaFromUrl method the SSL is verified by default. + * This is option disables SSL verification when downloading remote media. + * Please note that this is a security risk and should only be false in a local environment. + */ + 'media_downloader_ssl' => env('MEDIA_DOWNLOADER_SSL', true), + + /* + * The default lifetime in minutes for temporary urls. + * This is used when you call the `getLastTemporaryUrl` or `getLastTemporaryUrl` method on a media item. + */ + 'temporary_url_default_lifetime' => env('MEDIA_TEMPORARY_URL_DEFAULT_LIFETIME', 5), + + 'remote' => [ + /* + * Any extra headers that should be included when uploading media to + * a remote disk. Even though supported headers may vary between + * different drivers, a sensible default has been provided. + * + * Supported by S3: CacheControl, Expires, StorageClass, + * ServerSideEncryption, Metadata, ACL, ContentEncoding + */ + 'extra_headers' => [ + 'CacheControl' => 'max-age=604800', + ], + ], + + 'responsive_images' => [ + /* + * This class is responsible for calculating the target widths of the responsive + * images. By default we optimize for filesize and create variations that each are 30% + * smaller than the previous one. More info in the documentation. + * + * https://docs.spatie.be/laravel-medialibrary/v9/advanced-usage/generating-responsive-images + */ + 'width_calculator' => Spatie\MediaLibrary\ResponsiveImages\WidthCalculator\FileSizeOptimizedWidthCalculator::class, + + /* + * By default rendering media to a responsive image will add some javascript and a tiny placeholder. + * This ensures that the browser can already determine the correct layout. + * When disabled, no tiny placeholder is generated. + */ + 'use_tiny_placeholders' => true, + + /* + * This class will generate the tiny placeholder used for progressive image loading. By default + * the media library will use a tiny blurred jpg image. + */ + 'tiny_placeholder_generator' => Spatie\MediaLibrary\ResponsiveImages\TinyPlaceholderGenerator\Blurred::class, + ], + + /* + * When enabling this option, a route will be registered that will enable + * the Media Library Pro Vue and React components to move uploaded files + * in a S3 bucket to their right place. + */ + 'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false), + + /* + * When converting Media instances to response the media library will add + * a `loading` attribute to the `img` tag. Here you can set the default + * value of that attribute. + * + * Possible values: 'lazy', 'eager', 'auto' or null if you don't want to set any loading instruction. + * + * More info: https://css-tricks.com/native-lazy-loading/ + */ + 'default_loading_attribute_value' => null, + + /* + * You can specify a prefix for that is used for storing all media. + * If you set this to `/my-subdir`, all your media will be stored in a `/my-subdir` directory. + */ + 'prefix' => env('MEDIA_PREFIX', ''), + + /* + * When forcing lazy loading, media will be loaded even if you don't eager load media and you have + * disabled lazy loading globally in the service provider. + */ + 'force_lazy_loading' => env('FORCE_MEDIA_LIBRARY_LAZY_LOADING', true), +]; diff --git a/database/migrations/2026_01_02_160151_create_media_table.php b/database/migrations/2026_01_02_160151_create_media_table.php new file mode 100644 index 0000000..47a4be9 --- /dev/null +++ b/database/migrations/2026_01_02_160151_create_media_table.php @@ -0,0 +1,32 @@ +id(); + + $table->morphs('model'); + $table->uuid()->nullable()->unique(); + $table->string('collection_name'); + $table->string('name'); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('disk'); + $table->string('conversions_disk')->nullable(); + $table->unsignedBigInteger('size'); + $table->json('manipulations'); + $table->json('custom_properties'); + $table->json('generated_conversions'); + $table->json('responsive_images'); + $table->unsignedInteger('order_column')->nullable()->index(); + + $table->nullableTimestamps(); + }); + } +}; diff --git a/docs/decisions/004-spatie-media-library b/docs/decisions/004-spatie-media-library new file mode 100644 index 0000000..e1dc122 --- /dev/null +++ b/docs/decisions/004-spatie-media-library @@ -0,0 +1,10 @@ +# 2026-01-02 + +```bash +php artisan storage:link +composer require "spatie/laravel-medialibrary" +composer require filament/spatie-laravel-media-library-plugin:"^4.0" -W +php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-config" +php artisan migrate +``` + diff --git a/resources/js/app.js b/resources/js/app.js index e69de29..b677733 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -0,0 +1 @@ +console.log('App.js is loaded'); From d40b87438d939fb6c4dee4b0983278abda7617d1 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Fri, 2 Jan 2026 16:57:30 +0000 Subject: [PATCH 03/25] fix: add render hook for Vite in AdminPanelProvider --- app/Providers/Filament/AdminPanelProvider.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 373a4be..81aa43d 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -10,6 +10,8 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; +use Filament\Support\Facades\FilamentView; +use Filament\View\PanelsRenderHook; use Filament\Widgets\AccountWidget; use Filament\Widgets\FilamentInfoWidget; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -56,4 +58,12 @@ class AdminPanelProvider extends PanelProvider Authenticate::class, ]); } + + public function boot(): void + { + FilamentView::registerRenderHook( + PanelsRenderHook::BODY_END, + fn (): string => \Illuminate\Support\Facades\Blade::render('@vite("resources/js/app.js")'), + ); + } } From 02884d4e2b182b1d5c4d1939e04d575a9e2e79ef Mon Sep 17 00:00:00 2001 From: jon brookes Date: Fri, 2 Jan 2026 18:59:24 +0000 Subject: [PATCH 04/25] feat: implement Spatie Media Library integration with CRUD operations and media management UI --- .../Resources/Entries/Schemas/EntryForm.php | 50 ++++++++++- .../Resources/Entries/Tables/EntriesTable.php | 6 ++ .../Resources/Media/MediaResource.php | 58 +++++++++++++ .../Resources/Media/Pages/CreateMedia.php | 61 +++++++++++++ .../Resources/Media/Pages/EditMedia.php | 72 ++++++++++++++++ .../Resources/Media/Pages/ListMedia.php | 19 ++++ .../Resources/Media/Pages/ViewMedia.php | 19 ++++ .../Resources/Media/Schemas/MediaForm.php | 59 +++++++++++++ .../Resources/Media/Schemas/MediaInfolist.php | 36 ++++++++ .../Resources/Media/Tables/MediaTable.php | 86 +++++++++++++++++++ resources/js/app.js | 37 ++++++++ 11 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 app/Filament/Resources/Media/MediaResource.php create mode 100644 app/Filament/Resources/Media/Pages/CreateMedia.php create mode 100644 app/Filament/Resources/Media/Pages/EditMedia.php create mode 100644 app/Filament/Resources/Media/Pages/ListMedia.php create mode 100644 app/Filament/Resources/Media/Pages/ViewMedia.php create mode 100644 app/Filament/Resources/Media/Schemas/MediaForm.php create mode 100644 app/Filament/Resources/Media/Schemas/MediaInfolist.php create mode 100644 app/Filament/Resources/Media/Tables/MediaTable.php diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php index 82e163d..89c81b4 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryForm.php +++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php @@ -2,13 +2,16 @@ namespace App\Filament\Resources\Entries\Schemas; +use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\RichEditor; -use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Schema; use Illuminate\Support\Str; +use Spatie\MediaLibrary\MediaCollections\Models\Media; class EntryForm { @@ -18,13 +21,14 @@ class EntryForm ->components([ TextInput::make('title') ->required() - ->reactive() + ->live(onBlur: true) ->afterStateUpdated(function ($state, $set): void { $set('slug', Str::slug((string) $state)); }), TextInput::make('slug') ->required() - ->disabled(), + ->dehydrated() + ->readOnly(), Textarea::make('description') ->columnSpanFull(), Toggle::make('is_published') @@ -33,7 +37,45 @@ class EntryForm ->required(), DatePicker::make('published_at'), RichEditor::make('content') - ->columnSpanFull(), + ->columnSpanFull() + ->hintAction( + Action::make('picker') + ->label('Gallery Picker') + ->icon('heroicon-m-photo') + ->schema([ + Select::make('image_url') + ->label('Select an existing image') + ->allowHtml() + ->options(function () { + // We must 'get' the collection first so we can call getUrl() + // because 'url' is not a column in the Spatie database table. + return Media::latest() + ->get() + ->mapWithKeys(function (Media $item) { + $url = $item->getUrl(); + + $fileName = e($item->file_name); + $name = e($item->name ?? ''); + + $html = "
". + "\"{$fileName}\"/". + "{$name} — {$fileName}
"; + + return [$url => $html]; + })->toArray(); + }) + ->searchable() + ->required(), + ]) + ->action(function (array $data, RichEditor $component) { + // We dispatch the URL to the browser to be inserted into TipTap + $component->getLivewire()->dispatch('insert-editor-content', [ + 'statePath' => $component->getStatePath(), + 'html' => "", + ]); + }) + ), + ]); } } diff --git a/app/Filament/Resources/Entries/Tables/EntriesTable.php b/app/Filament/Resources/Entries/Tables/EntriesTable.php index 57035c0..4985529 100644 --- a/app/Filament/Resources/Entries/Tables/EntriesTable.php +++ b/app/Filament/Resources/Entries/Tables/EntriesTable.php @@ -7,6 +7,7 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\SpatieMediaLibraryImageColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; @@ -16,6 +17,11 @@ class EntriesTable { return $table ->columns([ + SpatieMediaLibraryImageColumn::make('featured_image') + ->collection('featured') + ->circular() + ->stacked() + ->limit(3), TextColumn::make('title') ->searchable(), TextColumn::make('slug') diff --git a/app/Filament/Resources/Media/MediaResource.php b/app/Filament/Resources/Media/MediaResource.php new file mode 100644 index 0000000..2ac19fe --- /dev/null +++ b/app/Filament/Resources/Media/MediaResource.php @@ -0,0 +1,58 @@ + ListMedia::route('/'), + 'create' => CreateMedia::route('/create'), + 'view' => ViewMedia::route('/{record}'), + 'edit' => EditMedia::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Media/Pages/CreateMedia.php b/app/Filament/Resources/Media/Pages/CreateMedia.php new file mode 100644 index 0000000..c652a96 --- /dev/null +++ b/app/Filament/Resources/Media/Pages/CreateMedia.php @@ -0,0 +1,61 @@ +uploadedFile = $file; + + // Set required fields for Media model + $data['model_type'] = $data['model_type'] ?? 'temp'; + $data['model_id'] = $data['model_id'] ?? 0; + $data['collection_name'] = $data['collection_name'] ?? 'default'; + $data['disk'] = $data['disk'] ?? 'public'; + $data['file_name'] = $file ? basename($file) : ''; + $data['mime_type'] = $file && Storage::disk('public')->exists($file) + ? Storage::disk('public')->mimeType($file) + : 'application/octet-stream'; + $data['size'] = $file && Storage::disk('public')->exists($file) + ? Storage::disk('public')->size($file) + : 0; + $data['manipulations'] = []; + $data['custom_properties'] = []; + $data['generated_conversions'] = []; + $data['responsive_images'] = []; + + return $data; + } + + protected function afterCreate(): void + { + if ($this->uploadedFile && $this->record) { + $disk = Storage::disk('public'); + + // Create the directory for this media ID (Spatie structure: {id}/{filename}) + $mediaDirectory = (string) $this->record->id; + $disk->makeDirectory($mediaDirectory); + + // Move file from temporary upload location to Spatie's expected location + if ($disk->exists($this->uploadedFile)) { + $newPath = $mediaDirectory.'/'.$this->record->file_name; + $disk->move($this->uploadedFile, $newPath); + } + } + } +} diff --git a/app/Filament/Resources/Media/Pages/EditMedia.php b/app/Filament/Resources/Media/Pages/EditMedia.php new file mode 100644 index 0000000..b6f4cdb --- /dev/null +++ b/app/Filament/Resources/Media/Pages/EditMedia.php @@ -0,0 +1,72 @@ +record->getPathRelativeToRoot()) { + $this->uploadedFile = $file; + + // Keep the original file_name to prevent breaking existing references + // $data['file_name'] is not updated - we preserve the original filename + $data['mime_type'] = Storage::disk('public')->exists($file) + ? Storage::disk('public')->mimeType($file) + : 'application/octet-stream'; + $data['size'] = Storage::disk('public')->exists($file) + ? Storage::disk('public')->size($file) + : 0; + } + + return $data; + } + + protected function afterSave(): void + { + if ($this->uploadedFile && $this->record) { + $disk = Storage::disk('public'); + $mediaDirectory = (string) $this->record->id; + + // Delete old file if it exists + $oldPath = $mediaDirectory.'/'.$this->record->getOriginal('file_name'); + if ($disk->exists($oldPath)) { + $disk->delete($oldPath); + } + + // Move new file to Spatie's expected location using the original filename + if ($disk->exists($this->uploadedFile)) { + $disk->makeDirectory($mediaDirectory); + // Use the original file_name to preserve existing references + $newPath = $mediaDirectory.'/'.$this->record->file_name; + $disk->move($this->uploadedFile, $newPath); + } + + // Redirect to the same page to refresh the form state + $this->redirect(static::getUrl(['record' => $this->record]), navigate: true); + } + } +} diff --git a/app/Filament/Resources/Media/Pages/ListMedia.php b/app/Filament/Resources/Media/Pages/ListMedia.php new file mode 100644 index 0000000..2e6b63d --- /dev/null +++ b/app/Filament/Resources/Media/Pages/ListMedia.php @@ -0,0 +1,19 @@ +components([ + TextInput::make('name') + ->required() + ->maxLength(255), + TextInput::make('collection_name') + ->default('default') + ->required() + ->maxLength(255), + Hidden::make('disk') + ->default('public'), + FileUpload::make('file') + ->label('File') + ->imageEditor() + ->imageEditorAspectRatios([ + '16:9', + '4:3', + '1:1', + ]) + ->columnSpanFull() + ->disk('public') + ->directory('media') + ->visibility('public') + ->acceptedFileTypes(['image/*', 'application/pdf']) + ->maxSize(10240) + ->required(fn ($context) => $context === 'create') + ->afterStateHydrated(function (FileUpload $component, $state, $record): void { + if (! $record) { + return; + } + + $media = $record; + + if (! $media instanceof SpatieMedia) { + return; + } + + // Construct the correct path: {media_id}/{filename} + $path = $media->id.'/'.$media->file_name; + + $component->state($path); + }), + ]); + } +} diff --git a/app/Filament/Resources/Media/Schemas/MediaInfolist.php b/app/Filament/Resources/Media/Schemas/MediaInfolist.php new file mode 100644 index 0000000..dfe4801 --- /dev/null +++ b/app/Filament/Resources/Media/Schemas/MediaInfolist.php @@ -0,0 +1,36 @@ +components([ + ImageEntry::make('file_name') + ->label('Preview') + ->getStateUsing(fn ($record) => $record->getUrl()) + ->visible(fn ($record) => $record->mime_type && str_starts_with($record->mime_type, 'image/')), + TextEntry::make('name'), + TextEntry::make('file_name'), + TextEntry::make('mime_type'), + TextEntry::make('collection_name'), + TextEntry::make('size') + ->formatStateUsing(fn ($state) => number_format($state / 1024, 2).' KB'), + TextEntry::make('model_type') + ->label('Attached to Model'), + TextEntry::make('model_id'), + TextEntry::make('custom_properties') + ->formatStateUsing(fn ($state) => json_encode($state, JSON_PRETTY_PRINT)), + TextEntry::make('created_at') + ->dateTime(), + TextEntry::make('updated_at') + ->dateTime(), + ]); + } +} diff --git a/app/Filament/Resources/Media/Tables/MediaTable.php b/app/Filament/Resources/Media/Tables/MediaTable.php new file mode 100644 index 0000000..dba76b0 --- /dev/null +++ b/app/Filament/Resources/Media/Tables/MediaTable.php @@ -0,0 +1,86 @@ +modifyQueryUsing(fn ($query) => $query->where('collection_name', '!=', 'avatars')) + ->columns([ + ImageColumn::make('url') + ->label('Preview') + ->getStateUsing(fn ($record) => + // Prefer the stored path produced by Filament's FileUpload (saved in custom_properties), + // fall back to Spatie's getUrl() when no stored_path exists. + ($record->getCustomProperty('stored_path')) + ? Storage::url($record->getCustomProperty('stored_path')) + : $record->getUrl() + ) + ->height(40) + ->width(40), + TextColumn::make('name') + ->searchable(), + TextColumn::make('file_name') + ->searchable(), + TextColumn::make('collection_name') + ->badge(), + TextColumn::make('mime_type'), + TextColumn::make('size') + ->formatStateUsing(fn ($state) => number_format($state / 1024, 2).' KB'), + TextColumn::make('created_at') + ->dateTime(), + ]) + ->filters([ + SelectFilter::make('collection_name') + ->options([ + 'images' => 'Images', + 'documents' => 'Documents', + ]), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make() + ->action(function (Media $record) { + // Delete the actual stored file path if we saved one, otherwise fall back to the Spatie path. + $stored = $record->getCustomProperty('stored_path'); + if ($stored) { + Storage::disk($record->disk)->delete($stored); + } else { + Storage::disk($record->disk)->delete($record->getPath()); + } + + $record->delete(); + }), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make() + ->action(function (Collection $records) { + $records->each(function (Media $record) { + $stored = $record->getCustomProperty('stored_path'); + if ($stored) { + Storage::disk($record->disk)->delete($stored); + } else { + Storage::disk($record->disk)->delete($record->getPath()); + } + $record->delete(); + }); + }), + ]), + ]); + } +} diff --git a/resources/js/app.js b/resources/js/app.js index b677733..b52c9ea 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1 +1,38 @@ console.log('App.js is loaded'); + +document.addEventListener('livewire:init', () => { + Livewire.on('insert-editor-content', (data) => { + console.log('Received insert-editor-content data:', data); + // Handle if data is an array + const payload = Array.isArray(data) ? data[0] : data; + const statePath = payload.statePath; + const html = payload.html; + console.log('Extracted statePath:', statePath, 'html:', html); + // 1. Find the editor by its statePath + const container = document.querySelector(`[wire\\:model="${statePath}"]`) || document.querySelector(`[statepath="${statePath}"]`); + console.log('Container found:', container); + const editorElement = container ? container.querySelector('.tiptap') : null; + console.log('Editor element found:', editorElement); + + if (editorElement && editorElement.editor) { + console.log('Inserting content:', html); + // 2. Insert the HTML (the tag) into the editor + setTimeout(() => { + editorElement.editor.chain().focus().insertContent(html).run(); + }, 500); + } else { + console.log('Editor not found or not initialized'); + // Fallback: try to find any .tiptap on the page + const anyTiptap = document.querySelector('.tiptap'); + console.log('Any tiptap found:', anyTiptap); + if (anyTiptap && anyTiptap.editor) { + console.log('Inserting to any tiptap'); + setTimeout(() => { + anyTiptap.editor.chain().focus().insertContent(html).run(); + }, 500); + } + } + }); +}); + +console.log('Testing if app.js is still running'); \ No newline at end of file From d24b9b07327cafaa51d99dd41ce1e8513ac34912 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sat, 3 Jan 2026 12:59:56 +0000 Subject: [PATCH 05/25] removed: filament/spatie-laravel-media-library-plugin --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 4745dfb..c67e0d1 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,6 @@ "require": { "php": "^8.2", "filament/filament": "^4.0", - "filament/spatie-laravel-media-library-plugin": "^4.0", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", From 9f01d44c9dd0ecde41224af231768479ab52b1fc Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sat, 3 Jan 2026 13:22:14 +0000 Subject: [PATCH 06/25] added feaured image to entry edit form now has image upload for featured image table view for entries shows featured image view entry shows featured image --- app/Filament/Resources/Entries/Schemas/EntryForm.php | 6 ++++++ app/Filament/Resources/Entries/Schemas/EntryInfolist.php | 4 ++++ app/Filament/Resources/Entries/Tables/EntriesTable.php | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php index 89c81b4..41c94e5 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryForm.php +++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php @@ -6,6 +6,7 @@ use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Select; +use Filament\Forms\Components\SpatieMediaLibraryFileUpload; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; @@ -31,6 +32,11 @@ class EntryForm ->readOnly(), Textarea::make('description') ->columnSpanFull(), + SpatieMediaLibraryFileUpload::make('featured_image') + ->collection('featured-image') + ->image() + ->imageEditor() + ->columnSpanFull(), Toggle::make('is_published') ->required(), Toggle::make('is_featured') diff --git a/app/Filament/Resources/Entries/Schemas/EntryInfolist.php b/app/Filament/Resources/Entries/Schemas/EntryInfolist.php index 6a5d87d..be741bc 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryInfolist.php +++ b/app/Filament/Resources/Entries/Schemas/EntryInfolist.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\Entries\Schemas; use Filament\Infolists\Components\IconEntry; +use Filament\Infolists\Components\SpatieMediaLibraryImageEntry; use Filament\Infolists\Components\TextEntry; use Filament\Schemas\Schema; @@ -17,6 +18,9 @@ class EntryInfolist TextEntry::make('description') ->placeholder('-') ->columnSpanFull(), + SpatieMediaLibraryImageEntry::make('featured_image') + ->collection('featured-image') + ->columnSpanFull(), IconEntry::make('is_published') ->boolean(), IconEntry::make('is_featured') diff --git a/app/Filament/Resources/Entries/Tables/EntriesTable.php b/app/Filament/Resources/Entries/Tables/EntriesTable.php index 4985529..eb89fd4 100644 --- a/app/Filament/Resources/Entries/Tables/EntriesTable.php +++ b/app/Filament/Resources/Entries/Tables/EntriesTable.php @@ -18,7 +18,7 @@ class EntriesTable return $table ->columns([ SpatieMediaLibraryImageColumn::make('featured_image') - ->collection('featured') + ->collection('featured-image') ->circular() ->stacked() ->limit(3), From 340b466aded2ee95f7fa732e02c937987b1d44a0 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sat, 3 Jan 2026 13:35:38 +0000 Subject: [PATCH 07/25] feat: enhance featured image upload with gallery selection and preview --- .../Resources/Entries/Schemas/EntryForm.php | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php index 41c94e5..a0613d3 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryForm.php +++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php @@ -36,7 +36,56 @@ class EntryForm ->collection('featured-image') ->image() ->imageEditor() - ->columnSpanFull(), + ->columnSpanFull() + ->hintAction( + Action::make('featured_picker') + ->label('Pick from Gallery') + ->icon('heroicon-m-photo') + ->schema([ + Select::make('image_id') + ->label('Select an existing image') + ->allowHtml() + ->options(function () { + return Media::latest() + ->get() + ->mapWithKeys(function (Media $item) { + $url = $item->getUrl(); + + $fileName = e($item->file_name); + $name = e($item->name ?? ''); + + $html = "
". + "\"{$fileName}\"/". + "{$name} — {$fileName}
"; + + return [$item->id => $html]; + })->toArray(); + }) + ->searchable() + ->required(), + ]) + ->action(function (array $data, SpatieMediaLibraryFileUpload $component) { + $record = $component->getRecord(); + + if ($record && $mediaItem = Media::find($data['image_id'])) { + // Clear any existing featured image + $record->clearMediaCollection('featured-image'); + + // Get the full path to the media file + $fullPath = $mediaItem->getPath(); + + // Add a copy of the media file to the featured-image collection + $record->addMedia($fullPath) + ->usingName($mediaItem->name ?: $mediaItem->file_name) + ->usingFileName($mediaItem->file_name) + ->preservingOriginal() + ->toMediaCollection('featured-image'); + + // Refresh the page to show the new featured image + $component->getLivewire()->dispatch('$refresh'); + } + }) + ), Toggle::make('is_published') ->required(), Toggle::make('is_featured') From c49249ee20d33ac545ab2bf8f5fbcab593460c95 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sat, 3 Jan 2026 14:25:23 +0000 Subject: [PATCH 08/25] fix: partical fix for featured imaes are added, seem semi permanent disapapar in media lib --- .../Resources/Entries/Schemas/EntryForm.php | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php index a0613d3..739fe50 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryForm.php +++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php @@ -11,6 +11,7 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Schemas\Schema; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Spatie\MediaLibrary\MediaCollections\Models\Media; @@ -67,22 +68,33 @@ class EntryForm ->action(function (array $data, SpatieMediaLibraryFileUpload $component) { $record = $component->getRecord(); - if ($record && $mediaItem = Media::find($data['image_id'])) { - // Clear any existing featured image + if (!$record) { + \Filament\Notifications\Notification::make() + ->warning() + ->title('Save the entry first') + ->send(); + return; + } + + if ($mediaItem = Media::find($data['image_id'])) { + // Clear existing featured image $record->clearMediaCollection('featured-image'); - // Get the full path to the media file - $fullPath = $mediaItem->getPath(); + // Download from the full URL and add as new media + $fullUrl = url($mediaItem->getUrl()); - // Add a copy of the media file to the featured-image collection - $record->addMedia($fullPath) + $newMedia = $record->addMediaFromUrl($fullUrl) ->usingName($mediaItem->name ?: $mediaItem->file_name) ->usingFileName($mediaItem->file_name) - ->preservingOriginal() ->toMediaCollection('featured-image'); - // Refresh the page to show the new featured image - $component->getLivewire()->dispatch('$refresh'); + // Update component state + $component->state([$newMedia->uuid]); + + \Filament\Notifications\Notification::make() + ->success() + ->title('Featured image set') + ->send(); } }) ), From a94a34ce3b0a81ec228fb65307c40d9b81232210 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sat, 3 Jan 2026 14:52:04 +0000 Subject: [PATCH 09/25] fix: storage to public this was breaking things bad --- app/Console/Commands/MoveMediaToPublic.php | 112 +++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 app/Console/Commands/MoveMediaToPublic.php diff --git a/app/Console/Commands/MoveMediaToPublic.php b/app/Console/Commands/MoveMediaToPublic.php new file mode 100644 index 0000000..bce2727 --- /dev/null +++ b/app/Console/Commands/MoveMediaToPublic.php @@ -0,0 +1,112 @@ +option('dry-run'); + + if ($dryRun) { + $this->warn('DRY RUN MODE - No files will actually be moved'); + } + + // Get all media records using local disk + $mediaRecords = Media::where('disk', 'local')->get(); + + if ($mediaRecords->isEmpty()) { + $this->info('No media records found using local disk.'); + return self::SUCCESS; + } + + $this->info("Found {$mediaRecords->count()} media records to migrate."); + + $progressBar = $this->output->createProgressBar($mediaRecords->count()); + $progressBar->start(); + + $moved = 0; + $errors = 0; + + foreach ($mediaRecords as $media) { + // Use relative path: {id}/{filename} + $relativePath = $media->id . '/' . $media->file_name; + + // Check if source file exists + if (!Storage::disk('local')->exists($relativePath)) { + $this->newLine(); + $this->error("Source file not found: {$relativePath}"); + $errors++; + $progressBar->advance(); + continue; + } + + try { + if (!$dryRun) { + // Copy file from local to public disk + $fileContent = Storage::disk('local')->get($relativePath); + Storage::disk('public')->put($relativePath, $fileContent); + + // Verify the file was copied successfully + if (Storage::disk('public')->exists($relativePath)) { + // Update the database record + $media->update([ + 'disk' => 'public', + 'conversions_disk' => 'public', + ]); + + // Delete the old file from local disk + Storage::disk('local')->delete($relativePath); + + $moved++; + } else { + throw new \Exception("Failed to copy file to public disk"); + } + } else { + $this->newLine(); + $this->line("Would move: local:{$relativePath} -> public:{$relativePath}"); + $moved++; + } + } catch (\Exception $e) { + $this->newLine(); + $this->error("Error moving {$relativePath}: {$e->getMessage()}"); + $errors++; + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + if ($dryRun) { + $this->info("DRY RUN: Would move {$moved} files, {$errors} errors encountered."); + $this->info("Run without --dry-run to actually perform the migration."); + } else { + $this->info("Successfully moved {$moved} files, {$errors} errors encountered."); + } + + return self::SUCCESS; + } +} From 6d1d88542e72aa0a9dc2248a92f297a0cae0e338 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sat, 3 Jan 2026 14:52:52 +0000 Subject: [PATCH 10/25] fix: storage to public private was breaking things bad --- app/Filament/Resources/Entries/Schemas/EntryForm.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php index 739fe50..43cc948 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryForm.php +++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php @@ -37,6 +37,8 @@ class EntryForm ->collection('featured-image') ->image() ->imageEditor() + ->disk('public') + ->visibility('public') ->columnSpanFull() ->hintAction( Action::make('featured_picker') @@ -86,7 +88,7 @@ class EntryForm $newMedia = $record->addMediaFromUrl($fullUrl) ->usingName($mediaItem->name ?: $mediaItem->file_name) ->usingFileName($mediaItem->file_name) - ->toMediaCollection('featured-image'); + ->toMediaCollection('featured-image', 'public'); // Update component state $component->state([$newMedia->uuid]); From 8e1650653b9e12375eb8870f383509b17ce3d1f6 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sat, 3 Jan 2026 15:12:34 +0000 Subject: [PATCH 11/25] fix: partical images broken public seems to be holding still issues with picking images - not saved --- .../Resources/Entries/Schemas/EntryForm.php | 75 +++++++++++++------ 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php index 43cc948..02b5aee 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryForm.php +++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php @@ -50,21 +50,36 @@ class EntryForm ->allowHtml() ->options(function () { return Media::latest() - ->get() + ->limit(30) // Limit to 30 most recent items for performance + ->get(['id', 'file_name', 'name', 'uuid', 'collection_name', 'model_type', 'model_id', 'disk']) + ->filter(function (Media $item) { + // Only include media items that have a valid disk + return $item->disk !== null; + }) ->mapWithKeys(function (Media $item) { - $url = $item->getUrl(); - + try { + $url = $item->getUrl(); + } catch (\Exception $e) { + // Skip items that can't generate URLs + return []; + } + $fileName = e($item->file_name); $name = e($item->name ?? ''); - - $html = "
". - "\"{$fileName}\"/". - "{$name} — {$fileName}
"; + + // Smaller image preview for better performance + $html = "
" . + "{$fileName}" . + "
" . + "{$name}" . + "{$fileName}" . + "
"; return [$item->id => $html]; })->toArray(); }) ->searchable() + ->preload() ->required(), ]) ->action(function (array $data, SpatieMediaLibraryFileUpload $component) { @@ -82,16 +97,15 @@ class EntryForm // Clear existing featured image $record->clearMediaCollection('featured-image'); - // Download from the full URL and add as new media - $fullUrl = url($mediaItem->getUrl()); - - $newMedia = $record->addMediaFromUrl($fullUrl) - ->usingName($mediaItem->name ?: $mediaItem->file_name) - ->usingFileName($mediaItem->file_name) - ->toMediaCollection('featured-image', 'public'); + // Associate the existing media with this entry instead of copying + $mediaItem->update([ + 'model_type' => get_class($record), + 'model_id' => $record->id, + 'collection_name' => 'featured-image' + ]); // Update component state - $component->state([$newMedia->uuid]); + $component->state([$mediaItem->uuid]); \Filament\Notifications\Notification::make() ->success() @@ -116,24 +130,37 @@ class EntryForm ->label('Select an existing image') ->allowHtml() ->options(function () { - // We must 'get' the collection first so we can call getUrl() - // because 'url' is not a column in the Spatie database table. return Media::latest() - ->get() + ->limit(30) // Limit to 30 most recent items for performance + ->get(['id', 'file_name', 'name', 'uuid', 'collection_name', 'model_type', 'model_id', 'disk']) + ->filter(function (Media $item) { + // Only include media items that have a valid disk + return $item->disk !== null; + }) ->mapWithKeys(function (Media $item) { - $url = $item->getUrl(); - + try { + $url = $item->getUrl(); + } catch (\Exception $e) { + // Skip items that can't generate URLs + return []; + } + $fileName = e($item->file_name); $name = e($item->name ?? ''); - - $html = "
". - "\"{$fileName}\"/". - "{$name} — {$fileName}
"; + + // Smaller image preview for better performance + $html = "
" . + "{$fileName}" . + "
" . + "{$name}" . + "{$fileName}" . + "
"; return [$url => $html]; })->toArray(); }) ->searchable() + ->preload() ->required(), ]) ->action(function (array $data, RichEditor $component) { From 93c977d1f5243579b58ba5dd46fb964e138e0f96 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sat, 3 Jan 2026 17:26:18 +0000 Subject: [PATCH 12/25] added fixes: warning some files likely wont be needed and wer added by ai to fix things that were no longer needed !!! --- .../Resources/Entries/Schemas/EntryForm.php | 104 +++++++++++------ app/Livewire/GalleryPicker.php | 108 ++++++++++++++++++ resources/js/app.js | 10 ++ .../featured-image-display.blade.php | 32 ++++++ .../views/livewire/gallery-picker.blade.php | 72 ++++++++++++ 5 files changed, 288 insertions(+), 38 deletions(-) create mode 100644 app/Livewire/GalleryPicker.php create mode 100644 resources/views/components/featured-image-display.blade.php create mode 100644 resources/views/livewire/gallery-picker.blade.php diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php index 02b5aee..6c029f6 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryForm.php +++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php @@ -40,6 +40,7 @@ class EntryForm ->disk('public') ->visibility('public') ->columnSpanFull() + ->dehydrated(false) ->hintAction( Action::make('featured_picker') ->label('Pick from Gallery') @@ -49,40 +50,36 @@ class EntryForm ->label('Select an existing image') ->allowHtml() ->options(function () { - return Media::latest() - ->limit(30) // Limit to 30 most recent items for performance - ->get(['id', 'file_name', 'name', 'uuid', 'collection_name', 'model_type', 'model_id', 'disk']) - ->filter(function (Media $item) { - // Only include media items that have a valid disk - return $item->disk !== null; - }) + return Media::where('model_type', 'temp') + ->where('model_id', 0) + ->where('disk', 'public') + ->latest() + ->limit(30) + ->get(['id', 'file_name', 'name', 'disk']) ->mapWithKeys(function (Media $item) { try { $url = $item->getUrl(); + $fileName = e($item->file_name); + $name = e($item->name ?? ''); + + $html = "
" . + "{$fileName}" . + "
" . + "{$name}" . + "{$fileName}" . + "
"; + + return [$item->id => $html]; } catch (\Exception $e) { - // Skip items that can't generate URLs return []; } - - $fileName = e($item->file_name); - $name = e($item->name ?? ''); - - // Smaller image preview for better performance - $html = "
" . - "{$fileName}" . - "
" . - "{$name}" . - "{$fileName}" . - "
"; - - return [$item->id => $html]; })->toArray(); }) ->searchable() ->preload() ->required(), ]) - ->action(function (array $data, SpatieMediaLibraryFileUpload $component) { + ->action(function (array $data, SpatieMediaLibraryFileUpload $component): void { $record = $component->getRecord(); if (!$record) { @@ -92,25 +89,56 @@ class EntryForm ->send(); return; } - - if ($mediaItem = Media::find($data['image_id'])) { - // Clear existing featured image - $record->clearMediaCollection('featured-image'); - - // Associate the existing media with this entry instead of copying - $mediaItem->update([ - 'model_type' => get_class($record), - 'model_id' => $record->id, - 'collection_name' => 'featured-image' - ]); - - // Update component state - $component->state([$mediaItem->uuid]); - + + if (!$data['image_id']) { + return; + } + + $sourceMedia = Media::find($data['image_id']); + if (!$sourceMedia || !file_exists($sourceMedia->getPath())) { + \Filament\Notifications\Notification::make() + ->danger() + ->title('Image file not found') + ->send(); + return; + } + + $sourceFile = $sourceMedia->getPath(); + $tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name; + copy($sourceFile, $tempCopy); + + try { + // Verify record has ID + if (!$record->id) { + \Filament\Notifications\Notification::make() + ->danger() + ->title('Entry must be saved first') + ->send(); + return; + } + + // Add the copy to the entry's featured-image collection + $newMedia = $record->addMedia($tempCopy) + ->usingName($sourceMedia->name ?: pathinfo($sourceMedia->file_name, PATHINFO_FILENAME)) + ->usingFileName($sourceMedia->file_name) + ->toMediaCollection('featured-image', 'public'); + + // Dispatch event for app.js to handle + $component->getLivewire()->dispatch('featured-image-added', ['mediaId' => $newMedia->id]); + \Filament\Notifications\Notification::make() ->success() - ->title('Featured image set') + ->title('Image added to featured image') ->send(); + } catch (\Exception $e) { + \Filament\Notifications\Notification::make() + ->danger() + ->title('Error: ' . $e->getMessage()) + ->send(); + } finally { + if (file_exists($tempCopy)) { + unlink($tempCopy); + } } }) ), diff --git a/app/Livewire/GalleryPicker.php b/app/Livewire/GalleryPicker.php new file mode 100644 index 0000000..c1da485 --- /dev/null +++ b/app/Livewire/GalleryPicker.php @@ -0,0 +1,108 @@ +entryId = $entryId; + $this->loadMediaItems(); + $this->showModal = true; + } + + public function loadMediaItems(): void + { + $this->mediaItems = Media::where('model_type', 'temp') + ->where('model_id', 0) + ->where('disk', 'public') + ->latest() + ->limit(30) + ->get(['id', 'file_name', 'name', 'disk']) + ->toArray(); + } + + public function selectMedia($mediaId): void + { + $this->selectedMediaId = $mediaId; + } + + public function copyToEntry(): void + { + if (!$this->selectedMediaId || !$this->entryId) { + $this->dispatch('notify-error', ['message' => 'Please select an image']); + return; + } + + $sourceMedia = Media::find($this->selectedMediaId); + if (!$sourceMedia) { + $this->dispatch('notify-error', ['message' => 'Media not found']); + return; + } + + try { + // Get the entry + $entry = \App\Models\Entry::find($this->entryId); + if (!$entry) { + $this->dispatch('notify-error', ['message' => 'Entry not found']); + return; + } + + // Get source file + $sourceFile = $sourceMedia->getPath(); + if (!file_exists($sourceFile)) { + $this->dispatch('notify-error', ['message' => 'Source file not found']); + return; + } + + // Create temp copy + $tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name; + copy($sourceFile, $tempCopy); + + try { + // Clear existing featured image + $entry->clearMediaCollection('featured-image'); + + // Add to entry + $newMedia = $entry->addMedia($tempCopy) + ->usingName($sourceMedia->name ?: pathinfo($sourceMedia->file_name, PATHINFO_FILENAME)) + ->usingFileName($sourceMedia->file_name) + ->toMediaCollection('featured-image', 'public'); + + // Close modal and notify + $this->showModal = false; + $this->selectedMediaId = null; + $this->dispatch('media-selected', ['mediaId' => $newMedia->id, 'fileName' => $newMedia->file_name]); + $this->dispatch('notify-success', ['message' => 'Image added to entry']); + } finally { + if (file_exists($tempCopy)) { + unlink($tempCopy); + } + } + } catch (\Exception $e) { + $this->dispatch('notify-error', ['message' => 'Error: ' . $e->getMessage()]); + } + } + + public function closePicker(): void + { + $this->showModal = false; + $this->selectedMediaId = null; + } + + public function render() + { + return view('livewire.gallery-picker'); + } +} diff --git a/resources/js/app.js b/resources/js/app.js index b52c9ea..22e1c9c 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -33,6 +33,16 @@ document.addEventListener('livewire:init', () => { } } }); + + Livewire.on('featured-image-added', (data) => { + console.log('Received featured-image-added event:', data); + const payload = Array.isArray(data) ? data[0] : data; + + // Reload the page to show the updated featured image + setTimeout(() => { + window.location.reload(); + }, 500); + }); }); console.log('Testing if app.js is still running'); \ No newline at end of file diff --git a/resources/views/components/featured-image-display.blade.php b/resources/views/components/featured-image-display.blade.php new file mode 100644 index 0000000..437552f --- /dev/null +++ b/resources/views/components/featured-image-display.blade.php @@ -0,0 +1,32 @@ +
+ @php + $record = $this->data; + if ($record && isset($record['id'])) { + $entry = \App\Models\Entry::find($record['id']); + $media = $entry ? $entry->getMedia('featured-image') : []; + } else { + $media = []; + } + @endphp + +
+ + @if(count($media) > 0) +
+ @foreach($media as $item) +
+
+ +
+

{{ $item->name }}

+

{{ $item->file_name }}

+
+
+
+ @endforeach +
+ @else +

No featured image selected

+ @endif +
+
diff --git a/resources/views/livewire/gallery-picker.blade.php b/resources/views/livewire/gallery-picker.blade.php new file mode 100644 index 0000000..103b66b --- /dev/null +++ b/resources/views/livewire/gallery-picker.blade.php @@ -0,0 +1,72 @@ +
+ @if($showModal ?? false) +
+
+ +
+ + +
+
+
+

+ Select Image from Gallery +

+ +
+ + @if(empty($mediaItems ?? [])) +

No images in gallery

+ @else +
+ @foreach($mediaItems ?? [] as $item) + + @endforeach +
+ @endif +
+ +
+ + +
+
+
+
+ @endif +
From 9f8c8d43f5c8c9214dfff0869710d662b44b0355 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sun, 4 Jan 2026 16:11:02 +0000 Subject: [PATCH 13/25] added filament spatie lib back in - must be needed after all --- .gitignore | 1 + composer.json | 4 +- composer.lock | 1621 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 1624 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3a324ad..eb4f16a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ yarn-error.log /.vscode /.zed .vite +*.deleted diff --git a/composer.json b/composer.json index c67e0d1..d7d1808 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "filament/filament": "^4.0", + "filament/spatie-laravel-media-library-plugin": "^4.4", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", @@ -26,7 +27,8 @@ "pestphp/pest-plugin-laravel": "^4.0" }, "autoload": { - "psr-4": { + "psr-4": { "laravel/pail": "^1.2.2", + "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" diff --git a/composer.lock b/composer.lock index 010c9b8..986c709 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bec347dcc3a450fc682920a766cbe019", + "content-hash": "2da5768872a6eb320b933b7f9b8baac4", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -8917,6 +8917,1228 @@ } ], "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-27T21:42:00+00:00" + }, + { + "name": "amphp/byte-stream", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/byte-stream.git", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\ByteStream\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A stream abstraction to make working with non-blocking I/O simple.", + "homepage": "https://amphp.org/byte-stream", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "non-blocking", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/byte-stream/issues", + "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T17:10:27+00:00" + }, + { + "name": "amphp/cache", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Cache\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/process": "^2", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "ext-json": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "dns", + "resolve" + ], + "support": { + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-01-19T15:43:40+00:00" + }, + { + "name": "amphp/hpack", + "version": "v3.2.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/hpack.git", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.10", + "phpunit/phpunit": "^7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "HTTP/2 HPack implementation.", + "homepage": "https://github.com/amphp/hpack", + "keywords": [ + "headers", + "hpack", + "http-2" + ], + "support": { + "issues": "https://github.com/amphp/hpack/issues", + "source": "https://github.com/amphp/hpack/tree/v3.2.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:00:16+00:00" + }, + { + "name": "amphp/http", + "version": "v2.1.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/http.git", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", + "shasum": "" + }, + "require": { + "amphp/hpack": "^3", + "amphp/parser": "^1.1", + "league/uri-components": "^2.4.2 | ^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "league/uri": "^6.8 | ^7.1", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.26.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/constants.php" + ], + "psr-4": { + "Amp\\Http\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Basic HTTP primitives which can be shared by servers and clients.", + "support": { + "issues": "https://github.com/amphp/http/issues", + "source": "https://github.com/amphp/http/tree/v2.1.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-11-23T14:57:26+00:00" + }, + { + "name": "amphp/http-client", + "version": "v5.3.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "75ad21574fd632594a2dd914496647816d5106bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", + "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "league/uri": "^7", + "league/uri-components": "^7", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "revolt/event-loop": "^1" + }, + "conflict": { + "amphp/file": "<3 | >=5" + }, + "require-dev": { + "amphp/file": "^3 | ^4", + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "ext-json": "*", + "kelunik/link-header-rfc5988": "^1", + "laminas/laminas-diactoros": "^2.3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "amphp/file": "Required for file request bodies and HTTP archive logging", + "ext-json": "Required for logging HTTP archives", + "ext-zlib": "Allows using compression for response bodies." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\Http\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", + "homepage": "https://amphp.org/http-client", + "keywords": [ + "async", + "client", + "concurrent", + "http", + "non-blocking", + "rest" + ], + "support": { + "issues": "https://github.com/amphp/http-client/issues", + "source": "https://github.com/amphp/http-client/tree/v5.3.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-16T20:41:23+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "7aa962b0569f664af3ba23bc819f2a69884329cd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/7aa962b0569f664af3ba23bc819f2a69884329cd", + "reference": "7aa962b0569f664af3ba23bc819f2a69884329cd", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/hpack": "^3", + "amphp/http": "^2", + "amphp/pipeline": "^1", + "amphp/socket": "^2.1", + "amphp/sync": "^2.2", + "league/uri": "^7.1", + "league/uri-interfaces": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1 | ^2", + "psr/log": "^1 | ^2 | ^3", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-client": "^5", + "amphp/log": "^2", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "league/uri-components": "^7.1", + "monolog/monolog": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "suggest": { + "ext-zlib": "Allows GZip compression of response bodies" + }, + "type": "library", + "autoload": { + "files": [ + "src/Driver/functions.php", + "src/Middleware/functions.php", + "src/functions.php" + ], + "psr-4": { + "Amp\\Http\\Server\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "A non-blocking HTTP application server for PHP based on Amp.", + "homepage": "https://github.com/amphp/http-server", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "server" + ], + "support": { + "issues": "https://github.com/amphp/http-server/issues", + "source": "https://github.com/amphp/http-server/tree/v3.4.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-05-18T15:43:42+00:00" + }, + { + "name": "amphp/parser", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/parser.git", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", + "shasum": "" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Parser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A generator parser to make streaming parsers simple.", + "homepage": "https://github.com/amphp/parser", + "keywords": [ + "async", + "non-blocking", + "parser", + "stream" + ], + "support": { + "issues": "https://github.com/amphp/parser/issues", + "source": "https://github.com/amphp/parser/tree/v1.1.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:16:53+00:00" + }, + { + "name": "amphp/pipeline", + "version": "v1.2.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "7b52598c2e9105ebcddf247fc523161581930367" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", + "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T16:33:53+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Process\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", + "support": { + "issues": "https://github.com/amphp/process/issues", + "source": "https://github.com/amphp/process/tree/v2.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+00:00" + }, + { + "name": "amphp/serialization", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/serialization.git", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", + "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "dev-master", + "phpunit/phpunit": "^9 || ^8 || ^7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Serialization\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Serialization tools for IPC and data storage in PHP.", + "homepage": "https://github.com/amphp/serialization", + "keywords": [ + "async", + "asynchronous", + "serialization", + "serialize" + ], + "support": { + "issues": "https://github.com/amphp/serialization/issues", + "source": "https://github.com/amphp/serialization/tree/master" + }, + "time": "2020-03-25T21:39:07+00:00" + }, + { + "name": "amphp/socket", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^6.5 | ^7", + "league/uri-interfaces": "^2.3 | ^7", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Internal/functions.php", + "src/SocketAddress/functions.php" + ], + "psr-4": { + "Amp\\Socket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", + "keywords": [ + "amp", + "async", + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" + ], + "support": { + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-21T14:33:03+00:00" + }, + { + "name": "amphp/sync", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Sync\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" + } + ], + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], + "support": { + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/websocket", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket.git", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", + "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/socket": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "suggest": { + "ext-zlib": "Required for compression" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + } + ], + "description": "Shared code for websocket servers and clients.", + "homepage": "https://github.com/amphp/websocket", + "keywords": [ + "amp", + "amphp", + "async", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket/issues", + "source": "https://github.com/amphp/websocket/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-10-28T21:28:45+00:00" + }, + { + "name": "amphp/websocket-client", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/amphp/websocket-client.git", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", + "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2.1", + "amphp/http": "^2.1", + "amphp/http-client": "^5", + "amphp/socket": "^2.2", + "amphp/websocket": "^2", + "league/uri": "^7.1", + "php": ">=8.1", + "psr/http-message": "^1|^2", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/http-server": "^3", + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/websocket-server": "^3|^4", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.26.1", + "psr/log": "^1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Websocket\\Client\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Async WebSocket client for PHP based on Amp.", + "keywords": [ + "amp", + "amphp", + "async", + "client", + "http", + "non-blocking", + "websocket" + ], + "support": { + "issues": "https://github.com/amphp/websocket-client/issues", + "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-24T17:25:34+00:00" + }, { "name": "brianium/paratest", "version": "v7.16.0", @@ -9010,6 +10232,50 @@ ], "time": "2025-12-09T20:03:26+00:00" }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, { "name": "doctrine/deprecations", "version": "1.1.5", @@ -9364,6 +10630,64 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "kelunik/certificate", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.0" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Kelunik\\Certificate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, { "name": "laravel/boost", "version": "v1.8.7", @@ -9430,6 +10754,80 @@ }, "time": "2025-12-19T15:04:12+00:00" }, + { + "name": "laravel/dusk", + "version": "v8.3.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/dusk.git", + "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/dusk/zipball/33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", + "reference": "33a4211c7b63ffe430bf30ec3c014012dcb6dfa6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-zip": "*", + "guzzlehttp/guzzle": "^7.5", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1", + "php-webdriver/webdriver": "^1.15.2", + "symfony/console": "^6.2|^7.0", + "symfony/finder": "^6.2|^7.0", + "symfony/process": "^6.2|^7.0", + "vlucas/phpdotenv": "^5.2" + }, + "require-dev": { + "laravel/framework": "^10.0|^11.0|^12.0", + "mockery/mockery": "^1.6", + "orchestra/testbench-core": "^8.19|^9.17|^10.8", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.1|^11.0|^12.0.1", + "psy/psysh": "^0.11.12|^0.12", + "symfony/yaml": "^6.2|^7.0" + }, + "suggest": { + "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Dusk\\DuskServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Dusk\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", + "keywords": [ + "laravel", + "testing", + "webdriver" + ], + "support": { + "issues": "https://github.com/laravel/dusk/issues", + "source": "https://github.com/laravel/dusk/tree/v8.3.4" + }, + "time": "2025-11-20T16:26:16+00:00" + }, { "name": "laravel/mcp", "version": "v0.5.1", @@ -10271,6 +11669,89 @@ ], "time": "2025-08-20T13:10:51+00:00" }, + { + "name": "pestphp/pest-plugin-browser", + "version": "v4.1.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-browser.git", + "reference": "da70fce21e4b33ba22bef1276f654e77676213d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/da70fce21e4b33ba22bef1276f654e77676213d7", + "reference": "da70fce21e4b33ba22bef1276f654e77676213d7", + "shasum": "" + }, + "require": { + "amphp/amp": "^3.1.1", + "amphp/http-server": "^3.4.3", + "amphp/websocket-client": "^2.0.2", + "ext-sockets": "*", + "pestphp/pest": "^4.1.0", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "symfony/process": "^7.3.4" + }, + "require-dev": { + "ext-pcntl": "*", + "ext-posix": "*", + "livewire/livewire": "^3.6.4", + "nunomaduro/collision": "^8.8.2", + "orchestra/testbench": "^10.6.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0.2" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Browser\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Browser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Pest plugin to test browser interactions", + "keywords": [ + "browser", + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.1.1" + }, + "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-09-29T01:31:33+00:00" + }, { "name": "pestphp/pest-plugin-laravel", "version": "v4.0.0", @@ -10595,6 +12076,72 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-webdriver/webdriver", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/php-webdriver/php-webdriver.git", + "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", + "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-zip": "*", + "php": "^7.3 || ^8.0", + "symfony/polyfill-mbstring": "^1.12", + "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "replace": { + "facebook/webdriver": "*" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.20.0", + "ondram/ci-detector": "^4.0", + "php-coveralls/php-coveralls": "^2.4", + "php-mock/php-mock-phpunit": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-simplexml": "For Firefox profile creation" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Exception/TimeoutException.php" + ], + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", + "homepage": "https://github.com/php-webdriver/php-webdriver", + "keywords": [ + "Chromedriver", + "geckodriver", + "php", + "selenium", + "webdriver" + ], + "support": { + "issues": "https://github.com/php-webdriver/php-webdriver/issues", + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" + }, + "time": "2025-12-28T23:57:40+00:00" + }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -11256,6 +12803,78 @@ ], "time": "2025-12-15T06:05:34+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.8", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" + }, + "time": "2025-08-27T21:33:23+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.0", From aa39707e105fa1028b1b7dd130382c22a6ffc67d Mon Sep 17 00:00:00 2001 From: jon brookes Date: Sun, 4 Jan 2026 16:17:38 +0000 Subject: [PATCH 14/25] fix: correct PSR-4 autoload configuration in composer.json --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index d7d1808..28fe120 100644 --- a/composer.json +++ b/composer.json @@ -27,8 +27,7 @@ "pestphp/pest-plugin-laravel": "^4.0" }, "autoload": { - "psr-4": { "laravel/pail": "^1.2.2", - + "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", "Database\\Seeders\\": "database/seeders/" From ff5ab3aa5891660ad1d657b0fa49d50dec788960 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 13:43:07 +0000 Subject: [PATCH 15/25] feat: add admin email configuration to app settings delete: remove outdated Spatie media library decision document docs: create new decision document for Spatie media library usage docs: add testing strategy document for Pest4 and Dusk test: implement login tests with Dusk for user authentication chore: add .gitignore files for console, screenshots, and source directories --- .github/copilot-instructions.md | 1 + .gitignore | 1 + GEMINI.md | 1 + app/Models/User.php | 17 +- composer.json | 1 + composer.lock | 1481 +------------------- config/app.php | 2 + docs/decisions/004-spatie-media-library | 10 - docs/decisions/004-spatie-media-library.md | 24 + docs/decisions/005-testing.md | 38 + tests/Browser/LoginTest.php | 98 ++ tests/Browser/console/.gitignore | 2 + tests/Browser/screenshots/.gitignore | 2 + tests/Browser/source/.gitignore | 2 + 14 files changed, 189 insertions(+), 1491 deletions(-) delete mode 100644 docs/decisions/004-spatie-media-library create mode 100644 docs/decisions/004-spatie-media-library.md create mode 100644 docs/decisions/005-testing.md create mode 100644 tests/Browser/LoginTest.php create mode 100644 tests/Browser/console/.gitignore create mode 100644 tests/Browser/screenshots/.gitignore create mode 100644 tests/Browser/source/.gitignore diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 690f1ee..a715f25 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,6 +15,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/prompts (PROMPTS) - v0 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v3 +- laravel/dusk (DUSK) - v8 - laravel/mcp (MCP) - v0 - laravel/pint (PINT) - v1 - laravel/sail (SAIL) - v1 diff --git a/.gitignore b/.gitignore index eb4f16a..f063bc9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ yarn-error.log /.zed .vite *.deleted +.env.dusk.local diff --git a/GEMINI.md b/GEMINI.md index 690f1ee..a715f25 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -15,6 +15,7 @@ This application is a Laravel application and its main Laravel ecosystems packag - laravel/prompts (PROMPTS) - v0 - livewire/flux (FLUXUI_FREE) - v2 - livewire/livewire (LIVEWIRE) - v3 +- laravel/dusk (DUSK) - v8 - laravel/mcp (MCP) - v0 - laravel/pint (PINT) - v1 - laravel/sail (SAIL) - v1 diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4..d73a69f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,13 +3,18 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; + +use Filament\Models\Contracts\FilamentUser; +use Filament\Panel; +use Illuminate\Container\Attributes\Log; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Log as FacadesLog; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; -class User extends Authenticatable +class User extends Authenticatable implements FilamentUser { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable, TwoFactorAuthenticatable; @@ -61,4 +66,14 @@ class User extends Authenticatable ->map(fn ($word) => Str::substr($word, 0, 1)) ->implode(''); } + + /** + * Determine if the user can access Filament admin panel. + */ + public function canAccessPanel(Panel $panel): bool + { + // FacadesLog::info('Checking admin access for user: ' . $this->email . ' against admin email: ' . config('app.admin_email')); + + return $this->email === config('app.admin_email'); + } } diff --git a/composer.json b/composer.json index 28fe120..1e5cca6 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "require-dev": { "fakerphp/faker": "^1.23", "laravel/boost": "^1.8", + "laravel/dusk": "^8.3", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index 986c709..83dd478 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2da5768872a6eb320b933b7f9b8baac4", + "content-hash": "8074d7e5af3ede59ab12a880d70b7989", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -8917,1228 +8917,6 @@ } ], "packages-dev": [ - { - "name": "amphp/amp", - "version": "v3.1.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/amp.git", - "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", - "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", - "shasum": "" - }, - "require": { - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "phpunit/phpunit": "^9", - "psalm/phar": "5.23.1" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Future/functions.php", - "src/Internal/functions.php" - ], - "psr-4": { - "Amp\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - } - ], - "description": "A non-blocking concurrency framework for PHP applications.", - "homepage": "https://amphp.org/amp", - "keywords": [ - "async", - "asynchronous", - "awaitable", - "concurrency", - "event", - "event-loop", - "future", - "non-blocking", - "promise" - ], - "support": { - "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/v3.1.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-08-27T21:42:00+00:00" - }, - { - "name": "amphp/byte-stream", - "version": "v2.1.2", - "source": { - "type": "git", - "url": "https://github.com/amphp/byte-stream.git", - "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46", - "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/parser": "^1.1", - "amphp/pipeline": "^1", - "amphp/serialization": "^1", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2.3" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "5.22.1" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Internal/functions.php" - ], - "psr-4": { - "Amp\\ByteStream\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "A stream abstraction to make working with non-blocking I/O simple.", - "homepage": "https://amphp.org/byte-stream", - "keywords": [ - "amp", - "amphp", - "async", - "io", - "non-blocking", - "stream" - ], - "support": { - "issues": "https://github.com/amphp/byte-stream/issues", - "source": "https://github.com/amphp/byte-stream/tree/v2.1.2" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-03-16T17:10:27+00:00" - }, - { - "name": "amphp/cache", - "version": "v2.0.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/cache.git", - "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", - "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/serialization": "^1", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\Cache\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - } - ], - "description": "A fiber-aware cache API based on Amp and Revolt.", - "homepage": "https://amphp.org/cache", - "support": { - "issues": "https://github.com/amphp/cache/issues", - "source": "https://github.com/amphp/cache/tree/v2.0.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-04-19T03:38:06+00:00" - }, - { - "name": "amphp/dns", - "version": "v2.4.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/dns.git", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", - "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/cache": "^2", - "amphp/parser": "^1", - "amphp/process": "^2", - "daverandom/libdns": "^2.0.2", - "ext-filter": "*", - "ext-json": "*", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "5.20" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Dns\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Chris Wright", - "email": "addr@daverandom.com" - }, - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - }, - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - } - ], - "description": "Async DNS resolution for Amp.", - "homepage": "https://github.com/amphp/dns", - "keywords": [ - "amp", - "amphp", - "async", - "client", - "dns", - "resolve" - ], - "support": { - "issues": "https://github.com/amphp/dns/issues", - "source": "https://github.com/amphp/dns/tree/v2.4.0" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-01-19T15:43:40+00:00" - }, - { - "name": "amphp/hpack", - "version": "v3.2.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/hpack.git", - "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", - "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "http2jp/hpack-test-case": "^1", - "nikic/php-fuzzer": "^0.0.10", - "phpunit/phpunit": "^7 | ^8 | ^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Amp\\Http\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - }, - { - "name": "Bob Weinand" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - } - ], - "description": "HTTP/2 HPack implementation.", - "homepage": "https://github.com/amphp/hpack", - "keywords": [ - "headers", - "hpack", - "http-2" - ], - "support": { - "issues": "https://github.com/amphp/hpack/issues", - "source": "https://github.com/amphp/hpack/tree/v3.2.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-03-21T19:00:16+00:00" - }, - { - "name": "amphp/http", - "version": "v2.1.2", - "source": { - "type": "git", - "url": "https://github.com/amphp/http.git", - "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", - "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588", - "shasum": "" - }, - "require": { - "amphp/hpack": "^3", - "amphp/parser": "^1.1", - "league/uri-components": "^2.4.2 | ^7.1", - "php": ">=8.1", - "psr/http-message": "^1 | ^2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "league/uri": "^6.8 | ^7.1", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.26.1" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Internal/constants.php" - ], - "psr-4": { - "Amp\\Http\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - } - ], - "description": "Basic HTTP primitives which can be shared by servers and clients.", - "support": { - "issues": "https://github.com/amphp/http/issues", - "source": "https://github.com/amphp/http/tree/v2.1.2" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-11-23T14:57:26+00:00" - }, - { - "name": "amphp/http-client", - "version": "v5.3.4", - "source": { - "type": "git", - "url": "https://github.com/amphp/http-client.git", - "reference": "75ad21574fd632594a2dd914496647816d5106bc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", - "reference": "75ad21574fd632594a2dd914496647816d5106bc", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/hpack": "^3", - "amphp/http": "^2", - "amphp/pipeline": "^1", - "amphp/socket": "^2", - "amphp/sync": "^2", - "league/uri": "^7", - "league/uri-components": "^7", - "league/uri-interfaces": "^7.1", - "php": ">=8.1", - "psr/http-message": "^1 | ^2", - "revolt/event-loop": "^1" - }, - "conflict": { - "amphp/file": "<3 | >=5" - }, - "require-dev": { - "amphp/file": "^3 | ^4", - "amphp/http-server": "^3", - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "ext-json": "*", - "kelunik/link-header-rfc5988": "^1", - "laminas/laminas-diactoros": "^2.3", - "phpunit/phpunit": "^9", - "psalm/phar": "~5.23" - }, - "suggest": { - "amphp/file": "Required for file request bodies and HTTP archive logging", - "ext-json": "Required for logging HTTP archives", - "ext-zlib": "Allows using compression for response bodies." - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Internal/functions.php" - ], - "psr-4": { - "Amp\\Http\\Client\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Lowrey", - "email": "rdlowrey@gmail.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - } - ], - "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", - "homepage": "https://amphp.org/http-client", - "keywords": [ - "async", - "client", - "concurrent", - "http", - "non-blocking", - "rest" - ], - "support": { - "issues": "https://github.com/amphp/http-client/issues", - "source": "https://github.com/amphp/http-client/tree/v5.3.4" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-08-16T20:41:23+00:00" - }, - { - "name": "amphp/http-server", - "version": "v3.4.3", - "source": { - "type": "git", - "url": "https://github.com/amphp/http-server.git", - "reference": "7aa962b0569f664af3ba23bc819f2a69884329cd" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/http-server/zipball/7aa962b0569f664af3ba23bc819f2a69884329cd", - "reference": "7aa962b0569f664af3ba23bc819f2a69884329cd", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/cache": "^2", - "amphp/hpack": "^3", - "amphp/http": "^2", - "amphp/pipeline": "^1", - "amphp/socket": "^2.1", - "amphp/sync": "^2.2", - "league/uri": "^7.1", - "league/uri-interfaces": "^7.1", - "php": ">=8.1", - "psr/http-message": "^1 | ^2", - "psr/log": "^1 | ^2 | ^3", - "revolt/event-loop": "^1" - }, - "require-dev": { - "amphp/http-client": "^5", - "amphp/log": "^2", - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "league/uri-components": "^7.1", - "monolog/monolog": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "~5.23" - }, - "suggest": { - "ext-zlib": "Allows GZip compression of response bodies" - }, - "type": "library", - "autoload": { - "files": [ - "src/Driver/functions.php", - "src/Middleware/functions.php", - "src/functions.php" - ], - "psr-4": { - "Amp\\Http\\Server\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - }, - { - "name": "Bob Weinand" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - } - ], - "description": "A non-blocking HTTP application server for PHP based on Amp.", - "homepage": "https://github.com/amphp/http-server", - "keywords": [ - "amp", - "amphp", - "async", - "http", - "non-blocking", - "server" - ], - "support": { - "issues": "https://github.com/amphp/http-server/issues", - "source": "https://github.com/amphp/http-server/tree/v3.4.3" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-05-18T15:43:42+00:00" - }, - { - "name": "amphp/parser", - "version": "v1.1.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/parser.git", - "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7", - "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7", - "shasum": "" - }, - "require": { - "php": ">=7.4" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\Parser\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "A generator parser to make streaming parsers simple.", - "homepage": "https://github.com/amphp/parser", - "keywords": [ - "async", - "non-blocking", - "parser", - "stream" - ], - "support": { - "issues": "https://github.com/amphp/parser/issues", - "source": "https://github.com/amphp/parser/tree/v1.1.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-03-21T19:16:53+00:00" - }, - { - "name": "amphp/pipeline", - "version": "v1.2.3", - "source": { - "type": "git", - "url": "https://github.com/amphp/pipeline.git", - "reference": "7b52598c2e9105ebcddf247fc523161581930367" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", - "reference": "7b52598c2e9105ebcddf247fc523161581930367", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "php": ">=8.1", - "revolt/event-loop": "^1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" - }, - "type": "library", - "autoload": { - "psr-4": { - "Amp\\Pipeline\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Asynchronous iterators and operators.", - "homepage": "https://amphp.org/pipeline", - "keywords": [ - "amp", - "amphp", - "async", - "io", - "iterator", - "non-blocking" - ], - "support": { - "issues": "https://github.com/amphp/pipeline/issues", - "source": "https://github.com/amphp/pipeline/tree/v1.2.3" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-03-16T16:33:53+00:00" - }, - { - "name": "amphp/process", - "version": "v2.0.3", - "source": { - "type": "git", - "url": "https://github.com/amphp/process.git", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", - "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/sync": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.4" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Process\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "A fiber-aware process manager based on Amp and Revolt.", - "homepage": "https://amphp.org/process", - "support": { - "issues": "https://github.com/amphp/process/issues", - "source": "https://github.com/amphp/process/tree/v2.0.3" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-04-19T03:13:44+00:00" - }, - { - "name": "amphp/serialization", - "version": "v1.0.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/serialization.git", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "phpunit/phpunit": "^9 || ^8 || ^7" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Serialization\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Serialization tools for IPC and data storage in PHP.", - "homepage": "https://github.com/amphp/serialization", - "keywords": [ - "async", - "asynchronous", - "serialization", - "serialize" - ], - "support": { - "issues": "https://github.com/amphp/serialization/issues", - "source": "https://github.com/amphp/serialization/tree/master" - }, - "time": "2020-03-25T21:39:07+00:00" - }, - { - "name": "amphp/socket", - "version": "v2.3.1", - "source": { - "type": "git", - "url": "https://github.com/amphp/socket.git", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/dns": "^2", - "ext-openssl": "*", - "kelunik/certificate": "^1.1", - "league/uri": "^6.5 | ^7", - "league/uri-interfaces": "^2.3 | ^7", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "amphp/process": "^2", - "phpunit/phpunit": "^9", - "psalm/phar": "5.20" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php", - "src/Internal/functions.php", - "src/SocketAddress/functions.php" - ], - "psr-4": { - "Amp\\Socket\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Daniel Lowrey", - "email": "rdlowrey@gmail.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", - "homepage": "https://github.com/amphp/socket", - "keywords": [ - "amp", - "async", - "encryption", - "non-blocking", - "sockets", - "tcp", - "tls" - ], - "support": { - "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.3.1" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-04-21T14:33:03+00:00" - }, - { - "name": "amphp/sync", - "version": "v2.3.0", - "source": { - "type": "git", - "url": "https://github.com/amphp/sync.git", - "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", - "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/pipeline": "^1", - "amphp/serialization": "^1", - "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "5.23" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Sync\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Stephen Coakley", - "email": "me@stephencoakley.com" - } - ], - "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", - "homepage": "https://github.com/amphp/sync", - "keywords": [ - "async", - "asynchronous", - "mutex", - "semaphore", - "synchronization" - ], - "support": { - "issues": "https://github.com/amphp/sync/issues", - "source": "https://github.com/amphp/sync/tree/v2.3.0" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-08-03T19:31:26+00:00" - }, - { - "name": "amphp/websocket", - "version": "v2.0.4", - "source": { - "type": "git", - "url": "https://github.com/amphp/websocket.git", - "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b", - "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2", - "amphp/parser": "^1", - "amphp/pipeline": "^1", - "amphp/socket": "^2", - "php": ">=8.1", - "revolt/event-loop": "^1" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" - }, - "suggest": { - "ext-zlib": "Required for compression" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Websocket\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - }, - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - } - ], - "description": "Shared code for websocket servers and clients.", - "homepage": "https://github.com/amphp/websocket", - "keywords": [ - "amp", - "amphp", - "async", - "http", - "non-blocking", - "websocket" - ], - "support": { - "issues": "https://github.com/amphp/websocket/issues", - "source": "https://github.com/amphp/websocket/tree/v2.0.4" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2024-10-28T21:28:45+00:00" - }, - { - "name": "amphp/websocket-client", - "version": "v2.0.2", - "source": { - "type": "git", - "url": "https://github.com/amphp/websocket-client.git", - "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c", - "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c", - "shasum": "" - }, - "require": { - "amphp/amp": "^3", - "amphp/byte-stream": "^2.1", - "amphp/http": "^2.1", - "amphp/http-client": "^5", - "amphp/socket": "^2.2", - "amphp/websocket": "^2", - "league/uri": "^7.1", - "php": ">=8.1", - "psr/http-message": "^1|^2", - "revolt/event-loop": "^1" - }, - "require-dev": { - "amphp/http-server": "^3", - "amphp/php-cs-fixer-config": "^2", - "amphp/phpunit-util": "^3", - "amphp/websocket-server": "^3|^4", - "phpunit/phpunit": "^9", - "psalm/phar": "~5.26.1", - "psr/log": "^1" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "Amp\\Websocket\\Client\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bob Weinand", - "email": "bobwei9@hotmail.com" - }, - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Async WebSocket client for PHP based on Amp.", - "keywords": [ - "amp", - "amphp", - "async", - "client", - "http", - "non-blocking", - "websocket" - ], - "support": { - "issues": "https://github.com/amphp/websocket-client/issues", - "source": "https://github.com/amphp/websocket-client/tree/v2.0.2" - }, - "funding": [ - { - "url": "https://github.com/amphp", - "type": "github" - } - ], - "time": "2025-08-24T17:25:34+00:00" - }, { "name": "brianium/paratest", "version": "v7.16.0", @@ -10232,50 +9010,6 @@ ], "time": "2025-12-09T20:03:26+00:00" }, - { - "name": "daverandom/libdns", - "version": "v2.1.0", - "source": { - "type": "git", - "url": "https://github.com/DaveRandom/LibDNS.git", - "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", - "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "php": ">=7.1" - }, - "suggest": { - "ext-intl": "Required for IDN support" - }, - "type": "library", - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "LibDNS\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "DNS protocol implementation written in pure PHP", - "keywords": [ - "dns" - ], - "support": { - "issues": "https://github.com/DaveRandom/LibDNS/issues", - "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" - }, - "time": "2024-04-12T12:12:48+00:00" - }, { "name": "doctrine/deprecations", "version": "1.1.5", @@ -10630,64 +9364,6 @@ }, "time": "2025-03-19T14:43:43+00:00" }, - { - "name": "kelunik/certificate", - "version": "v1.1.3", - "source": { - "type": "git", - "url": "https://github.com/kelunik/certificate.git", - "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", - "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", - "shasum": "" - }, - "require": { - "ext-openssl": "*", - "php": ">=7.0" - }, - "require-dev": { - "amphp/php-cs-fixer-config": "^2", - "phpunit/phpunit": "^6 | 7 | ^8 | ^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Kelunik\\Certificate\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Access certificate details and transform between different formats.", - "keywords": [ - "DER", - "certificate", - "certificates", - "openssl", - "pem", - "x509" - ], - "support": { - "issues": "https://github.com/kelunik/certificate/issues", - "source": "https://github.com/kelunik/certificate/tree/v1.1.3" - }, - "time": "2023-02-03T21:26:53+00:00" - }, { "name": "laravel/boost", "version": "v1.8.7", @@ -11669,89 +10345,6 @@ ], "time": "2025-08-20T13:10:51+00:00" }, - { - "name": "pestphp/pest-plugin-browser", - "version": "v4.1.1", - "source": { - "type": "git", - "url": "https://github.com/pestphp/pest-plugin-browser.git", - "reference": "da70fce21e4b33ba22bef1276f654e77676213d7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/da70fce21e4b33ba22bef1276f654e77676213d7", - "reference": "da70fce21e4b33ba22bef1276f654e77676213d7", - "shasum": "" - }, - "require": { - "amphp/amp": "^3.1.1", - "amphp/http-server": "^3.4.3", - "amphp/websocket-client": "^2.0.2", - "ext-sockets": "*", - "pestphp/pest": "^4.1.0", - "pestphp/pest-plugin": "^4.0.0", - "php": "^8.3", - "symfony/process": "^7.3.4" - }, - "require-dev": { - "ext-pcntl": "*", - "ext-posix": "*", - "livewire/livewire": "^3.6.4", - "nunomaduro/collision": "^8.8.2", - "orchestra/testbench": "^10.6.0", - "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-laravel": "^4.0", - "pestphp/pest-plugin-type-coverage": "^4.0.2" - }, - "type": "library", - "extra": { - "pest": { - "plugins": [ - "Pest\\Browser\\Plugin" - ] - } - }, - "autoload": { - "files": [ - "src/Autoload.php" - ], - "psr-4": { - "Pest\\Browser\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Pest plugin to test browser interactions", - "keywords": [ - "browser", - "framework", - "pest", - "php", - "test", - "testing", - "unit" - ], - "support": { - "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.1.1" - }, - "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-09-29T01:31:33+00:00" - }, { "name": "pestphp/pest-plugin-laravel", "version": "v4.0.0", @@ -12803,78 +11396,6 @@ ], "time": "2025-12-15T06:05:34+00:00" }, - { - "name": "revolt/event-loop", - "version": "v1.0.8", - "source": { - "type": "git", - "url": "https://github.com/revoltphp/event-loop.git", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", - "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "ext-json": "*", - "jetbrains/phpstorm-stubs": "^2019.3", - "phpunit/phpunit": "^9", - "psalm/phar": "^5.15" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Revolt\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Aaron Piotrowski", - "email": "aaron@trowski.com" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "ceesjank@gmail.com" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering" - }, - { - "name": "Niklas Keller", - "email": "me@kelunik.com" - } - ], - "description": "Rock-solid event loop for concurrent PHP applications.", - "keywords": [ - "async", - "asynchronous", - "concurrency", - "event", - "event-loop", - "non-blocking", - "scheduler" - ], - "support": { - "issues": "https://github.com/revoltphp/event-loop/issues", - "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8" - }, - "time": "2025-08-27T21:33:23+00:00" - }, { "name": "sebastian/cli-parser", "version": "4.2.0", diff --git a/config/app.php b/config/app.php index 423eed5..bf88713 100644 --- a/config/app.php +++ b/config/app.php @@ -123,4 +123,6 @@ return [ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + 'admin_email' => env('ADMIN_EMAIL', ''), + ]; diff --git a/docs/decisions/004-spatie-media-library b/docs/decisions/004-spatie-media-library deleted file mode 100644 index e1dc122..0000000 --- a/docs/decisions/004-spatie-media-library +++ /dev/null @@ -1,10 +0,0 @@ -# 2026-01-02 - -```bash -php artisan storage:link -composer require "spatie/laravel-medialibrary" -composer require filament/spatie-laravel-media-library-plugin:"^4.0" -W -php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-config" -php artisan migrate -``` - diff --git a/docs/decisions/004-spatie-media-library.md b/docs/decisions/004-spatie-media-library.md new file mode 100644 index 0000000..64431d3 --- /dev/null +++ b/docs/decisions/004-spatie-media-library.md @@ -0,0 +1,24 @@ +# 2026-01-02 + +```bash +php artisan storage:link +composer require "spatie/laravel-medialibrary" +composer require filament/spatie-laravel-media-library-plugin:"^4.0" -W +php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-config" +php artisan migrate +``` +I had a lot of fun and silly games working out if I even needed both Spatie media library and the filament plugin they have as it states itself a sub directory of filament itself. + +I managed to remove the spatie fulament plugin and for the app to work for a while but eventually found a part of it not implemented in filament core. It took a while but there seems to be an overlap somewhere. So I re-installed the spatie filament module to find all the errors went away, when previously they did not + +PHP and I guess composer would seem to aggressively cache things very very hard and I just didnt understand how very hard that is sometimes. + +Suffice to say, spatie media library seems to be 'the way' as far as media is concerned for filament. + +I looked at curator, also by the author of the tiptap filament integration to find it seems dependant on tailwind3, limiting me to Laravel 11 I believe, which was not what I wanted. + +I used filament 'actions' in order to augment the form for 'entries' so as to add 'pick from gallery' functionality together with custom javascript to insert records found in media library for both rich content and featured images + +There may be a better way of doing this but for now, I believe this is a solution I have craved for a while now with filament and have at least gained a bit better understanding of so called 'actions' and will re-visit this whole section in the docs. + + diff --git a/docs/decisions/005-testing.md b/docs/decisions/005-testing.md new file mode 100644 index 0000000..eb0c372 --- /dev/null +++ b/docs/decisions/005-testing.md @@ -0,0 +1,38 @@ +# 2026-01-05 + +I would very much like to start using Pest4 as it has a lot of nice things in it like browser testing with some of the latest screen shot visual testing and more + +for now I know Dusk and have been able to use this in previous projects before Pest4 + +so initially at least I intend to use Dusk and build on to migrate to Pest4 where I can + +# test isolation and setup + +in the past I now realize, I was introducing brittle tests by first doing setup to then rely on a configuration for further tests + +I think this is a pattern that some follow but as a generalist, devops and hybrid person I can and do make mistakes. I suppose we all do. + +Mine was to not fully embrace test isolation + +Each test therefor needs to be set up from scratch so to speak + +I then have each subsequent test re-setting up say, a user, a database table, whatever is required but each time from a blank slate + +That way, when a test fails, it should be obvious what has been broken without having to traverse through a bunch of setup and dependencies to find I broke a setup step, not a test. + +Let us hope so anyway. That is the approach I am going with with Dusk for now. Lets see how I get on and if I get the setup code as non repeated by abstraction as I can + +I think testing and particulary automated testing is essential for success. + +It has been disappointing to find in my time working with others in the past that there seems resistance to testing and to automation of testing for reasons I believe not technical but that which time does not allow for me to digress + +## confusing config + +In config files: Just use the key name without any prefix + +In your code: Use config('filename.keyname') + +The filename becomes the first part automatically - you never type it inside the config file itself. + + + diff --git a/tests/Browser/LoginTest.php b/tests/Browser/LoginTest.php new file mode 100644 index 0000000..eecd256 --- /dev/null +++ b/tests/Browser/LoginTest.php @@ -0,0 +1,98 @@ +create([ + 'email' => $email, + 'password' => bcrypt('password'), + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + ]); + } + + private function loginUser(Browser $browser, User $user): void + { + $browser->visit('/login') + ->type('email', $user->email) + ->type('password', 'password') + ->press('Log in') + ->waitForLocation('/dashboard'); + } + + public function test_login(): void + { + $user = $this->createTestUser("login-test@example.com"); + + $this->browse(function (Browser $browser) use ($user) { + $this->loginUser($browser, $user); + try { + $browser->assertPathIs('/dashboard'); // Or wherever successful login redirects + } catch (\Exception $e) { + $browser->pause(10000); // Pause for 10 seconds on failure to debug + throw $e; + } + }); + } + + /* + public function test_invalid_login(): void + { + $user = $this->createTestUser("invalid-email@example.com"); + + + $this->browse(function (Browser $browser) use ($user) { + $this->loginUser($browser, $user); + try { + $browser->visit('/admin') + ->waitForLocation('/admin') + ->assertPathIs('/admin') + ->assertTitleContains('Dashboard') + ->assertDontSee('Forbidden') + ->pause(1000); + } catch (\Exception $e) { + $browser->pause(1000); // Pause for 1 second on failure to debug + throw $e; + } + }); + + + + + } +*/ + + public function test_access_admin_panel(): void + { + + $user = $this->createTestUser("login-test@example.com"); + + $this->browse(function (Browser $browser) use ($user) { + $this->loginUser($browser, $user); + try { + $browser->visit('/admin') + ->waitForLocation('/admin') + ->assertPathIs('/admin') + ->assertTitleContains('Dashboard') + ->assertDontSee('Forbidden') + ->pause(1000); + } catch (\Exception $e) { + $browser->pause(1000); // Pause for 1 second on failure to debug + throw $e; + } + }); + } +} diff --git a/tests/Browser/console/.gitignore b/tests/Browser/console/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/Browser/console/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Browser/screenshots/.gitignore b/tests/Browser/screenshots/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/Browser/screenshots/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/Browser/source/.gitignore b/tests/Browser/source/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/Browser/source/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore From 1e35e485ade2a16c65b79089e83fe3691ee10db3 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 14:56:40 +0000 Subject: [PATCH 16/25] initial partically working --- tests/Browser/LoginDashAdminTest.php | 60 ++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/Browser/LoginDashAdminTest.php diff --git a/tests/Browser/LoginDashAdminTest.php b/tests/Browser/LoginDashAdminTest.php new file mode 100644 index 0000000..b5d2bc3 --- /dev/null +++ b/tests/Browser/LoginDashAdminTest.php @@ -0,0 +1,60 @@ +createTestUser("login-test@example.com"); + + $this->browse(function (Browser $browser) use ($user) { + $this->loginUser($browser, $user); + $this->assertWithDebugPause($browser, fn($b) => + $b->assertPathIs('/dashboard'), + 1000 // Custom pause time for this test + ); + }); + } + + public function test_invalid_login(): void + { + $user = $this->createTestUser("invalid-email@example.com"); + + $this->browse(function (Browser $browser) use ($user) { + $this->loginUser($browser, $user); + $this->assertWithDebugPause($browser, fn($b) => + $b->visit('/admin') + ->waitForLocation('/admin') + ->assertPathIs('/admin') + ->assertSee('FORBIDDEN'), + 1000 // Custom pause time for this test + ); + }); + } + + public function test_access_admin_panel(): void + { + $user = $this->createTestUser("login-test@example.com"); + + $this->browse(function (Browser $browser) use ($user) { + $this->loginUser($browser, $user); + $this->assertWithDebugPause($browser, fn($b) => + $b->visit('/admin') + ->waitForLocation('/admin') + ->assertPathIs('/admin') + ->assertTitleContains('Dashboard') + ->assertDontSee('FORBIDDEN'), + 1000 // Custom pause time for this test + ); + }); + } +} \ No newline at end of file From 4cb9d078b1ed00a64e6fffb2b7ecd380f75ae7e0 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 15:05:27 +0000 Subject: [PATCH 17/25] initial partically working --- tests/Browser/UploadImageAdminTest.php | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/Browser/UploadImageAdminTest.php diff --git a/tests/Browser/UploadImageAdminTest.php b/tests/Browser/UploadImageAdminTest.php new file mode 100644 index 0000000..bd94b6d --- /dev/null +++ b/tests/Browser/UploadImageAdminTest.php @@ -0,0 +1,45 @@ +createTestUser("login-test@example.com"); + + $filePath = base_path('tests/Browser/fixtures/robot.webp'); + + $this->browse(function (Browser $browser ) use ($user, $filePath) { + $this->loginUser($browser, $user); + $this->assertWithDebugPause( + $browser, + fn($b) => + $b->visit('/admin/media') + ->waitForLocation('/admin/media') + ->assertPathIs('/admin/media') + ->assertTitleContains('Media') + ->clickLink('New media') + ->waitForText('Create Media') + ->pause(1000) + ->assertVisible('.filepond--drop-label') + ->attach('.filepond--browser', $filePath) + ->waitforText('Create') + ->clickLink('Create') + // ->assertSee('Upload successful') + ->pause(10000), + 1000 // Custom pause time for this test + ); + }); + } +} From bef3ae7f41df6597fb6d1f5041c0f0358fb52a07 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 16:37:06 +0000 Subject: [PATCH 18/25] feat: implement user authentication traits and tests for admin panel image uploads --- tests/Browser/Concerns/AuthenticatesUsers.php | 38 +++++++ tests/Browser/CreateEntryAdminTest.php | 61 +++++++++++ tests/Browser/LoginTest.php | 98 ------------------ tests/Browser/UploadImageAdminTest.php | 13 +-- tests/Browser/fixtures/robot.webp | Bin 0 -> 15864 bytes 5 files changed, 106 insertions(+), 104 deletions(-) create mode 100644 tests/Browser/Concerns/AuthenticatesUsers.php create mode 100644 tests/Browser/CreateEntryAdminTest.php delete mode 100644 tests/Browser/LoginTest.php create mode 100644 tests/Browser/fixtures/robot.webp diff --git a/tests/Browser/Concerns/AuthenticatesUsers.php b/tests/Browser/Concerns/AuthenticatesUsers.php new file mode 100644 index 0000000..640fbd0 --- /dev/null +++ b/tests/Browser/Concerns/AuthenticatesUsers.php @@ -0,0 +1,38 @@ +create([ + 'email' => $email, + 'password' => bcrypt('password'), + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + ]); + } + + private function loginUser(Browser $browser, User $user): void + { + $browser->visit('/login') + ->type('email', $user->email) + ->type('password', 'password') + ->press('Log in') + ->waitForLocation('/dashboard'); + } + + private function assertWithDebugPause(Browser $browser, callable $assertions, int $pauseMs = 10000): void + { + try { + $assertions($browser); + } catch (\Exception $e) { + $browser->pause($pauseMs); + throw $e; + } + } +} \ No newline at end of file diff --git a/tests/Browser/CreateEntryAdminTest.php b/tests/Browser/CreateEntryAdminTest.php new file mode 100644 index 0000000..25598bc --- /dev/null +++ b/tests/Browser/CreateEntryAdminTest.php @@ -0,0 +1,61 @@ +createTestUser("login-test@example.com"); + + $filePath = base_path('tests/Browser/fixtures/robot.webp'); + + $this->browse(function (Browser $browser) use ($user, $filePath) { + $this->loginUser($browser, $user); + $this->assertWithDebugPause( + $browser, + fn($b) => + $b->visit('/admin/media') + ->waitForLocation('/admin/media') + ->assertPathIs('/admin/media') + ->assertTitleContains('Media') + ->clickLink('New media') + ->waitForText('Create Media') + ->type('#form\\.name', 'test image') + ->assertVisible('.filepond--drop-label') + ->attach('.filepond--browser', $filePath) + ->pause(7000) + ->waitForText('Create') + ->waitFor('#key-bindings-1:not([disabled])') + ->click('#key-bindings-1') + ->assertSee('Collection name') + ->pause(5000) + + ->visit('/admin/entries') + ->waitForLocation('/admin/entries') + ->assertPathIs('/admin/entries') + ->assertTitleContains('Entries') + ->clickLink('New entry') + ->waitForText('Create Entry') + ->type('#form\\.title', 'TEST ENTRY') + ->keys('#form\\.title', '{tab}') + ->waitForText('Create') + + ->click('#key-bindings-1') + ->waitForText('Updated at') + ->assertSee('Updated at') + ->visit('/admin/entries/1/edit') + ->pause(10000), + 1000 // Custom pause time for this test + ); + }); + } +} diff --git a/tests/Browser/LoginTest.php b/tests/Browser/LoginTest.php deleted file mode 100644 index eecd256..0000000 --- a/tests/Browser/LoginTest.php +++ /dev/null @@ -1,98 +0,0 @@ -create([ - 'email' => $email, - 'password' => bcrypt('password'), - 'two_factor_secret' => null, - 'two_factor_recovery_codes' => null, - ]); - } - - private function loginUser(Browser $browser, User $user): void - { - $browser->visit('/login') - ->type('email', $user->email) - ->type('password', 'password') - ->press('Log in') - ->waitForLocation('/dashboard'); - } - - public function test_login(): void - { - $user = $this->createTestUser("login-test@example.com"); - - $this->browse(function (Browser $browser) use ($user) { - $this->loginUser($browser, $user); - try { - $browser->assertPathIs('/dashboard'); // Or wherever successful login redirects - } catch (\Exception $e) { - $browser->pause(10000); // Pause for 10 seconds on failure to debug - throw $e; - } - }); - } - - /* - public function test_invalid_login(): void - { - $user = $this->createTestUser("invalid-email@example.com"); - - - $this->browse(function (Browser $browser) use ($user) { - $this->loginUser($browser, $user); - try { - $browser->visit('/admin') - ->waitForLocation('/admin') - ->assertPathIs('/admin') - ->assertTitleContains('Dashboard') - ->assertDontSee('Forbidden') - ->pause(1000); - } catch (\Exception $e) { - $browser->pause(1000); // Pause for 1 second on failure to debug - throw $e; - } - }); - - - - - } -*/ - - public function test_access_admin_panel(): void - { - - $user = $this->createTestUser("login-test@example.com"); - - $this->browse(function (Browser $browser) use ($user) { - $this->loginUser($browser, $user); - try { - $browser->visit('/admin') - ->waitForLocation('/admin') - ->assertPathIs('/admin') - ->assertTitleContains('Dashboard') - ->assertDontSee('Forbidden') - ->pause(1000); - } catch (\Exception $e) { - $browser->pause(1000); // Pause for 1 second on failure to debug - throw $e; - } - }); - } -} diff --git a/tests/Browser/UploadImageAdminTest.php b/tests/Browser/UploadImageAdminTest.php index bd94b6d..915078c 100644 --- a/tests/Browser/UploadImageAdminTest.php +++ b/tests/Browser/UploadImageAdminTest.php @@ -7,7 +7,7 @@ use Laravel\Dusk\Browser; use Tests\Browser\Concerns\AuthenticatesUsers; use Tests\DuskTestCase; -class LoginTest extends DuskTestCase +class UploadImageAdminTest extends DuskTestCase { use DatabaseTruncation; use AuthenticatesUsers; @@ -31,13 +31,14 @@ class LoginTest extends DuskTestCase ->assertTitleContains('Media') ->clickLink('New media') ->waitForText('Create Media') - ->pause(1000) + ->type('#form\\.name', 'test image') ->assertVisible('.filepond--drop-label') ->attach('.filepond--browser', $filePath) - ->waitforText('Create') - ->clickLink('Create') - // ->assertSee('Upload successful') - ->pause(10000), + ->pause(7000) + ->waitForText('Create') + ->waitFor('#key-bindings-1:not([disabled])') + ->click('#key-bindings-1') + ->assertSee('Collection name'), 1000 // Custom pause time for this test ); }); diff --git a/tests/Browser/fixtures/robot.webp b/tests/Browser/fixtures/robot.webp new file mode 100644 index 0000000000000000000000000000000000000000..3dd400ba7f62396a2332cfa072ef414f75f5d9b4 GIT binary patch literal 15864 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 O?SH2pZmT Date: Mon, 5 Jan 2026 17:17:48 +0000 Subject: [PATCH 19/25] feat: custom class in entry form and update Dusk test for featured image selection --- app/Filament/Resources/Entries/Schemas/EntryForm.php | 3 ++- tests/Browser/CreateEntryAdminTest.php | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/Filament/Resources/Entries/Schemas/EntryForm.php b/app/Filament/Resources/Entries/Schemas/EntryForm.php index 6c029f6..b7bdf36 100644 --- a/app/Filament/Resources/Entries/Schemas/EntryForm.php +++ b/app/Filament/Resources/Entries/Schemas/EntryForm.php @@ -43,8 +43,9 @@ class EntryForm ->dehydrated(false) ->hintAction( Action::make('featured_picker') - ->label('Pick from Gallery') + ->label('Featured Image from Gallery') ->icon('heroicon-m-photo') + ->extraAttributes(['id' => 'featured-picker-button']) ->schema([ Select::make('image_id') ->label('Select an existing image') diff --git a/tests/Browser/CreateEntryAdminTest.php b/tests/Browser/CreateEntryAdminTest.php index 25598bc..b874411 100644 --- a/tests/Browser/CreateEntryAdminTest.php +++ b/tests/Browser/CreateEntryAdminTest.php @@ -7,7 +7,7 @@ use Laravel\Dusk\Browser; use Tests\Browser\Concerns\AuthenticatesUsers; use Tests\DuskTestCase; -class UploadImageAdminTest extends DuskTestCase +class CreateEntryAdminTest extends DuskTestCase { use DatabaseTruncation; use AuthenticatesUsers; @@ -53,6 +53,11 @@ class UploadImageAdminTest extends DuskTestCase ->waitForText('Updated at') ->assertSee('Updated at') ->visit('/admin/entries/1/edit') + ->waitForText('Edit TEST ENTRY') + ->pause(2000) + ->waitForText('Featured Image') + ->click('#featured-picker-button') + ->pause(10000), 1000 // Custom pause time for this test ); From 50e5fb7f3fb79c0dcec8352b07dcf8ece85e32b0 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 17:40:22 +0000 Subject: [PATCH 20/25] feat: enhance media entry test to select existing images --- tests/Browser/CreateEntryAdminTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Browser/CreateEntryAdminTest.php b/tests/Browser/CreateEntryAdminTest.php index b874411..38ab34f 100644 --- a/tests/Browser/CreateEntryAdminTest.php +++ b/tests/Browser/CreateEntryAdminTest.php @@ -57,7 +57,9 @@ class CreateEntryAdminTest extends DuskTestCase ->pause(2000) ->waitForText('Featured Image') ->click('#featured-picker-button') - + ->waitForText('Select an existing image') + ->click('.fi-select-input-btn') + ->keys('.fi-select-input-btn', '{enter}') ->pause(10000), 1000 // Custom pause time for this test ); From 33688b55be7d414a4ecb50c1f25d787f482c0d33 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 18:38:05 +0000 Subject: [PATCH 21/25] feat: add critical testing approach guidelines for Laravel Dusk --- .github/copilot-instructions.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a715f25..0077032 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -44,6 +44,13 @@ This application is a Laravel application and its main Laravel ecosystems packag ## Documentation Files - You must only create documentation files if explicitly requested by the user. +## Testing Approach - CRITICAL +- **THE APPLICATION IS 100% WORKING** - All functionality works perfectly in production. +- When writing or debugging browser tests (Laravel Dusk), focus ONLY on test syntax, selectors, and Dusk interaction methods. +- NEVER assume the application has bugs or suggest app fixes - the issue is always in the test code. +- Trust the existing functionality and work on getting the correct CSS selectors, XPath expressions, and Dusk methods. +- If manual interaction works but the test fails, the problem is the test implementation, not the app. + === boost rules === From b0fc008530eb920669e1daf02d3d8ba80b538aff Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 18:42:02 +0000 Subject: [PATCH 22/25] feat: complete image upload to entry test enhance entry creation test to select existing images and submit --- tests/Browser/CreateEntryAdminTest.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Browser/CreateEntryAdminTest.php b/tests/Browser/CreateEntryAdminTest.php index 38ab34f..dc05e3b 100644 --- a/tests/Browser/CreateEntryAdminTest.php +++ b/tests/Browser/CreateEntryAdminTest.php @@ -59,8 +59,14 @@ class CreateEntryAdminTest extends DuskTestCase ->click('#featured-picker-button') ->waitForText('Select an existing image') ->click('.fi-select-input-btn') - ->keys('.fi-select-input-btn', '{enter}') - ->pause(10000), + ->pause(2000) + ->click('li:first-child') + ->waitForText('Submit') + ->clickAtXPath('//button[contains(., "Submit")]') + + ->waitForText('Edit TEST ENTRY') + ->click('#key-bindings-1'), + // ->pause(20000), 1000 // Custom pause time for this test ); }); From cfb93534759ab6175ff2b2fd9ba30dee2bbf2ee4 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 18:44:24 +0000 Subject: [PATCH 23/25] feat: extend Pest with DuskTestCase for browser testing --- tests/Pest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Pest.php b/tests/Pest.php index b3a25b7..59833e1 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,9 @@ extend(Tests\DuskTestCase::class) +// ->use(Illuminate\Foundation\Testing\DatabaseMigrations::class) + ->in('Browser'); + /* |-------------------------------------------------------------------------- | Test Case From 56ce59fc22533838dfa598cade4ccf95537c4290 Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 18:48:13 +0000 Subject: [PATCH 24/25] feat: add DuskTestCase for enhanced browser testing setup --- tests/DuskTestCase.php | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/DuskTestCase.php diff --git a/tests/DuskTestCase.php b/tests/DuskTestCase.php new file mode 100644 index 0000000..020699d --- /dev/null +++ b/tests/DuskTestCase.php @@ -0,0 +1,48 @@ +addArguments(collect([ + $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080', + '--disable-search-engine-choice-screen', + '--disable-smooth-scrolling', + ])->unless($this->hasHeadlessDisabled(), function (Collection $items) { + return $items->merge([ + '--disable-gpu', + '--headless=new', + ]); + })->all()); + + return RemoteWebDriver::create( + $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515', + DesiredCapabilities::chrome()->setCapability( + ChromeOptions::CAPABILITY, $options + ) + ); + } +} From 22393b5954e0fee7b48b09b9d6f70bebc81ecaef Mon Sep 17 00:00:00 2001 From: jon brookes Date: Mon, 5 Jan 2026 19:04:04 +0000 Subject: [PATCH 25/25] feat: change order of Dusk tests for login, image upload, and entry creation in admin panel --- .../{LoginDashAdminTest.php => 001_LoginDashAdminTest.php} | 0 .../{UploadImageAdminTest.php => 002_UploadImageAdminTest.php} | 0 .../{CreateEntryAdminTest.php => 003_CreateEntryAdminTest.php} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests/Browser/{LoginDashAdminTest.php => 001_LoginDashAdminTest.php} (100%) rename tests/Browser/{UploadImageAdminTest.php => 002_UploadImageAdminTest.php} (100%) rename tests/Browser/{CreateEntryAdminTest.php => 003_CreateEntryAdminTest.php} (100%) diff --git a/tests/Browser/LoginDashAdminTest.php b/tests/Browser/001_LoginDashAdminTest.php similarity index 100% rename from tests/Browser/LoginDashAdminTest.php rename to tests/Browser/001_LoginDashAdminTest.php diff --git a/tests/Browser/UploadImageAdminTest.php b/tests/Browser/002_UploadImageAdminTest.php similarity index 100% rename from tests/Browser/UploadImageAdminTest.php rename to tests/Browser/002_UploadImageAdminTest.php diff --git a/tests/Browser/CreateEntryAdminTest.php b/tests/Browser/003_CreateEntryAdminTest.php similarity index 100% rename from tests/Browser/CreateEntryAdminTest.php rename to tests/Browser/003_CreateEntryAdminTest.php