Feature laravel13#91
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the package for Laravel 13 / Flysystem v3, refactors the Cloudinary adapter into smaller components, and modernizes the test suite and tooling to align with the new Flysystem exception/return-type expectations.
Changes:
- Upgrade support matrix and tooling: Laravel 13, PHP 8.3–8.5, Pest 4, Testbench 11, Larastan 3.9; update CI workflows accordingly.
- Refactor
FlysystemCloudinaryAdapterto use new collaborators (CloudinaryDiskOptions,CloudinaryPathNormalizer,CloudinaryResponseMapper,CloudinaryResourceOperations, etc.) and to throw Flysystem v3 exceptions for failure cases. - Restructure tests: split the previous adapter test into focused unit/feature tests; add guarded live integration tests and update docs/config examples.
Reviewed changes
Copilot reviewed 42 out of 42 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/FlysystemCloudinaryServiceProvider.php | Passes resolved disk options into the adapter when registering the Storage driver. |
| src/FlysystemCloudinaryAdapter.php | Major refactor to Flysystem v3 behavior (exceptions, attributes, shallow listing), and delegates work to new helper classes. |
| src/Concerns/InteractsWithCloudinaryMetadata.php | Extracts metadata retrieval behavior used by the adapter. |
| src/CloudinaryUrlBuilder.php | Centralizes URL building and explicit-url selection. |
| src/CloudinaryStringUploadSource.php | Encapsulates temp-file creation for string uploads. |
| src/CloudinaryResponseMapper.php | Centralizes response normalization and attribute mapping. |
| src/CloudinaryResponseLogger.php | Centralizes dispatching response log events. |
| src/CloudinaryResourceOperations.php | Encapsulates explicit/destroy resource-type iteration and logging. |
| src/CloudinaryPathNormalizer.php | Normalizes logical vs folder-prefixed Cloudinary public IDs. |
| src/CloudinaryListResponseAssembler.php | Builds shallow list results as FileAttributes/DirectoryAttributes. |
| src/CloudinaryDiskOptions.php | Resolves disk/published config and merges upload options deterministically. |
| src/CloudinaryAdminFolderLocator.php | Implements paginated folder existence checks via Admin API. |
| tests/Pest.php | Adds integration helper include, groups integration tests, and sets adapter fixtures for feature adapter tests. |
| tests/cloudinary_integration.php | Adds helper to skip live integration tests unless real credentials are present. |
| tests/Integration/CloudinaryTest.php | Updates integration assertions to Flysystem v3 exception behavior and adds additional cases. |
| tests/Feature/FlysystemCloudinaryServiceProviderTest.php | Verifies the Storage driver registers and yields the expected adapter. |
| tests/Feature/AdapterTest.php | Removes monolithic adapter test in favor of split feature tests. |
| tests/Feature/Adapter/UrlTest.php | Feature tests for URL building behavior. |
| tests/Feature/Adapter/UploadWriteTest.php | Feature tests for write/update and metadata behavior. |
| tests/Feature/Adapter/RenameCopyTest.php | Feature tests for rename/copy behavior and failure exceptions. |
| tests/Feature/Adapter/ReadListHasTest.php | Feature tests for readStream/listing/has behavior. |
| tests/Feature/Adapter/DeleteDirectoryTest.php | Feature tests for delete/deleteDirectory behaviors and folder prefixing. |
| tests/Unit/InteractsWithCloudinaryMetadataTest.php | Unit coverage for metadata mapping and exception wrapping. |
| tests/Unit/FlysystemCloudinaryResponseLogTest.php | Unit test for event payload storage. |
| tests/Unit/CloudinaryUrlBuilderTest.php | Unit tests for URL builder behaviors and event dispatching. |
| tests/Unit/CloudinaryStringUploadSourceTest.php | Unit tests for temp source creation for string uploads. |
| tests/Unit/CloudinaryResponseMapperTest.php | Unit tests for normalization/mapping edge cases. |
| tests/Unit/CloudinaryResponseLoggerTest.php | Unit test for dispatching response log events. |
| tests/Unit/CloudinaryResourceOperationsTest.php | Unit tests for resource-type iteration and destroy semantics. |
| tests/Unit/CloudinaryPathNormalizerTest.php | Unit tests for folder prefixing/logical path conversion. |
| tests/Unit/CloudinaryListResponseAssemblerTest.php | Unit tests for assembling shallow lists to Flysystem attributes. |
| tests/Unit/CloudinaryDiskOptionsTest.php | Unit tests for disk/published config resolution and merge order. |
| tests/Unit/CloudinaryAdminFolderLocatorTest.php | Unit tests for paginated folder existence checks. |
| README.md | Updates Laravel 13/Flysystem 3 docs, exception semantics, testing commands, and config examples. |
| phpunit.xml.dist | Switches to FILESYSTEM_DISK placeholder and keeps Cloudinary env placeholders. |
| composer.json | Bumps platform/framework/tooling versions; updates scripts to Pest/PHPStan/Pint. |
| CHANGELOG.md | Documents 13.0.0 changes and behavioral differences. |
| .github/workflows/run-tests.yml | Updates test matrix (PHP/Laravel), config prep step, and test command. |
| .github/workflows/phpstan.yml | Bumps PHP version used for static analysis. |
| .github/workflows/fix-php-code-style-issues.yml | Switches to explicit PHP setup + composer install + pint run. |
| .github/workflows/composer-audit.yml | Bumps PHP version used for dependency audit. |
| .github/ISSUE_TEMPLATE/bug_report.yml | Updates placeholders for PHP/Laravel versions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| try { | ||
| $response = $this->resourceOps->explicit($this->cloudinary, $prefixed); | ||
| } catch (NotFound) { | ||
| return false; | ||
| } | ||
|
|
||
| $this->logger->log($response); | ||
|
|
There was a problem hiding this comment.
CloudinaryResourceOperations::explicit() already logs the successful ApiResponse before returning it. Logging again here causes duplicate FlysystemCloudinaryResponseLog events for the same response (and forces callers/tests to expect 2 dispatches). Consider removing this extra log (and the $logger dependency) so each API response is logged exactly once by the component that performs the call.
| } | ||
|
|
||
| $folder = trim(trim($this->folder), '/'); | ||
|
|
There was a problem hiding this comment.
prefixed() always concatenates "{$folder}/{$path}" when a folder is configured, which yields a trailing slash when $path is empty (e.g. listing root with a configured folder becomes folder/). Cloudinary Admin API folder and prefix parameters are typically slash-sensitive; returning folder (no trailing slash) for an empty path would be safer and avoids inconsistent identifiers.
| if ($path === '') { | |
| return $folder; | |
| } |
| expect($builder->urlViaExplicit('file'))->toBe('https://secure'); | ||
| Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 2); | ||
| }); |
There was a problem hiding this comment.
This assertion currently expects 2 FlysystemCloudinaryResponseLog dispatches, but the implementation logs the same explicit ApiResponse twice (once inside CloudinaryResourceOperations::explicit() and again in CloudinaryUrlBuilder::urlViaExplicit()). If the duplicate logging is removed, update this expectation to 1 dispatch to match the intended "one event per API response" behavior.
| return [ | ||
| 'contents' => $body, | ||
| 'etag' => Arr::get($response, 'etag'), | ||
| 'mimetype' => $this->mimeDetector->detectMimeType($logicalPath, $body) ?? 'text/plain', |
There was a problem hiding this comment.
FinfoMimeTypeDetector::detectMimeType() expects the file contents as a string (or null depending on implementation), but normalizeUploadOrExplicit() passes $body which can be a stream resource (e.g. writeStream() / updateStream()). This can cause a TypeError at runtime when the adapter is used with real stream resources. Consider guarding with is_string($body) (and passing null/'' otherwise), or using a stream/file-path based detector when $body is a resource.
| 'mimetype' => $this->mimeDetector->detectMimeType($logicalPath, $body) ?? 'text/plain', | |
| 'mimetype' => $this->mimeDetector->detectMimeType($logicalPath, is_string($body) ? $body : null) ?? 'text/plain', |
|
|
||
| $adapter->writeStream($publicId, $contents, new Config); | ||
|
|
||
| $meta = $adapter->lastUploadMetadata(); | ||
| $this->assertSame($contents, $meta['contents']); | ||
| $this->assertSame('::etag::', $meta['etag']); | ||
| $this->assertSame('text/plain', $meta['mimetype']); | ||
| $this->assertSame($publicId, $meta['path']); | ||
| $this->assertSame(789, $meta['size']); | ||
| $this->assertSame(1633860610, $meta['timestamp']); | ||
| $this->assertSame('file', $meta['type']); | ||
| $this->assertSame(123456, $meta['version']); | ||
| $this->assertSame('::version-id::', $meta['versionid']); | ||
| $this->assertSame('public', $meta['visibility']); | ||
| Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); |
There was a problem hiding this comment.
This test passes a string as the $resource argument to writeStream(), but the Flysystem adapter contract expects a stream resource. Using a real resource (e.g. fopen('php://temp', 'r+') and writing $contents into it) would better reflect real usage and would catch stream-specific issues (like MIME detection / rewind handling).
| $adapter->writeStream($publicId, $contents, new Config); | |
| $meta = $adapter->lastUploadMetadata(); | |
| $this->assertSame($contents, $meta['contents']); | |
| $this->assertSame('::etag::', $meta['etag']); | |
| $this->assertSame('text/plain', $meta['mimetype']); | |
| $this->assertSame($publicId, $meta['path']); | |
| $this->assertSame(789, $meta['size']); | |
| $this->assertSame(1633860610, $meta['timestamp']); | |
| $this->assertSame('file', $meta['type']); | |
| $this->assertSame(123456, $meta['version']); | |
| $this->assertSame('::version-id::', $meta['versionid']); | |
| $this->assertSame('public', $meta['visibility']); | |
| Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); | |
| $resource = fopen('php://temp', 'r+'); | |
| fwrite($resource, $contents); | |
| rewind($resource); | |
| try { | |
| $adapter->writeStream($publicId, $resource, new Config); | |
| $meta = $adapter->lastUploadMetadata(); | |
| $this->assertSame($contents, $meta['contents']); | |
| $this->assertSame('::etag::', $meta['etag']); | |
| $this->assertSame('text/plain', $meta['mimetype']); | |
| $this->assertSame($publicId, $meta['path']); | |
| $this->assertSame(789, $meta['size']); | |
| $this->assertSame(1633860610, $meta['timestamp']); | |
| $this->assertSame('file', $meta['type']); | |
| $this->assertSame(123456, $meta['version']); | |
| $this->assertSame('::version-id::', $meta['versionid']); | |
| $this->assertSame('public', $meta['visibility']); | |
| Event::assertDispatched(FlysystemCloudinaryResponseLog::class, 1); | |
| } finally { | |
| fclose($resource); | |
| } |
| it('can update stream', function () { | ||
| $publicId = '::file-path::'; | ||
| $contents = '::file-contents::'; | ||
| $mock = $this->mock(Cloudinary::class, function (MockInterface $mock) use ($publicId) { | ||
| $mock->shouldReceive('uploadApi->upload')->once()->andReturn(new ApiResponse([ | ||
| 'public_id' => $publicId, | ||
| 'version' => 123456, | ||
| 'version_id' => '::version-id::', | ||
| 'created_at' => '2021-10-10T10:10:10Z', | ||
| 'bytes' => 789, | ||
| 'etag' => '::etag::', | ||
| 'access_mode' => 'public', | ||
| ], [])); | ||
| }); | ||
| $adapter = new FlysystemCloudinaryAdapter($mock); | ||
|
|
||
| $meta = $adapter->updateStream($publicId, $contents, new Config); | ||
|
|
There was a problem hiding this comment.
This test passes a string as the $resource argument to updateStream(), but updateStream() is meant to receive a stream resource. Switching the test to provide an actual resource would exercise the intended code path and help prevent regressions specific to stream uploads.
| if (fwrite($tempFile, $contents) === false) { | ||
| throw UnableToReadFile::fromLocation($errorPath); | ||
| } | ||
| if (rewind($tempFile) === false) { |
There was a problem hiding this comment.
contentsToTempStream() can leak the temporary stream resource on error: when fwrite() or rewind() fails, an exception is thrown without fclose($tempFile). Consider closing the handle before throwing in those failure branches to avoid resource leaks in long-running processes.
| if (fwrite($tempFile, $contents) === false) { | |
| throw UnableToReadFile::fromLocation($errorPath); | |
| } | |
| if (rewind($tempFile) === false) { | |
| if (fwrite($tempFile, $contents) === false) { | |
| fclose($tempFile); | |
| throw UnableToReadFile::fromLocation($errorPath); | |
| } | |
| if (rewind($tempFile) === false) { | |
| fclose($tempFile); |
No description provided.