Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ We're sorry to hear you have a problem. Can you help us solve it by providing th
attributes:
label: PHP Version
description: What version of PHP are you running? Please be as specific as possible
placeholder: "8.4.0"
value: "8.4.0"
placeholder: "8.5.0"
value: "8.5.0"
validations:
required: true
- type: input
id: laravel-version
attributes:
label: Laravel Version
description: What version of Laravel are you running? Please be as specific as possible
placeholder: "12.0.0"
value: "12.0.0"
placeholder: "13.0.0"
value: "13.0.0"
validations:
required: true
- type: dropdown
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/composer-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@2.37.0
with:
php-version: '8.4'
php-version: '8.5'
coverage: none

- name: Resolve dependencies and audit
Expand Down
13 changes: 11 additions & 2 deletions .github/workflows/fix-php-code-style-issues.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ jobs:
- name: Checkout code
uses: actions/checkout@v6.0.2

- name: Fix PHP code style issues
uses: aglipanci/laravel-pint-action@2.6
- name: Setup PHP
uses: shivammathur/setup-php@2.37.0
with:
php-version: '8.5'
coverage: none

- name: Install composer dependencies
uses: ramsey/composer-install@4.0.0

- name: Run Laravel Pint
run: vendor/bin/pint

- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v7.1.0
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/phpstan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@2.37.0
with:
php-version: '8.4'
php-version: '8.5'
coverage: none

- name: Install composer dependencies
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ jobs:
max-parallel: 1
matrix:
os: [ ubuntu-latest ]
php: [ 8.2, 8.3, 8.4 ]
laravel: [ 12.* ]
php: [ 8.3, 8.4, 8.5 ]
laravel: [ 13.* ]
stability: [ prefer-lowest, prefer-stable ]

name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}

steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6.0.2

- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@2.37.0
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
Expand All @@ -45,7 +45,7 @@ jobs:
run: cp phpunit.xml.dist phpunit.xml

- name: Execute tests
run: vendor/bin/pest
run: vendor/bin/pest --no-coverage
env:
CLOUDINARY_CLOUD_NAME: ${{ secrets.CLOUDINARY_CLOUD_NAME }}
CLOUDINARY_API_KEY: ${{ secrets.CLOUDINARY_API_KEY }}
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

All notable changes to `laravel-cloudinary` will be documented in this file.

## 13.0.0 - 2026-04-03

- Laravel 13 support
- PHP 8.3, 8.4, and 8.5 support (PHP 8.2 dropped)
- Development tooling: Orchestra Testbench 11, Pest 4, Larastan 3.9
- Packagist `dev-main` branch alias `13.x-dev` for Composer until `v13.0.0` is tagged
- **Flysystem 3:** `listContents()` now returns `FileAttributes` / `DirectoryAttributes` so `Storage::files()`, `Storage::directories()`, and related APIs work (fixes [#64](https://github.com/codebar-ag/laravel-flysystem-cloudinary/issues/64), [#80](https://github.com/codebar-ag/laravel-flysystem-cloudinary/issues/80))
- **Flysystem 3:** `read()` / `readStream()` throw `UnableToReadFile` on failure; `readStream()` returns a `resource`; `copy()` throws `UnableToCopyFile`; `delete()` throws `UnableToDeleteFile` when destroy fails; `createDirectory()` / `deleteDirectory()` throw the corresponding Flysystem exceptions
- Apply configured folder prefix to `move()`, `createDirectory()`, `deleteDirectory()`, and fix `directoryExists()` for root-level paths
- Fix string uploads: rewind temp stream after `fwrite()` before Cloudinary `upload()`
- `createDir()` catches `ApiError` as well as `RateLimited`
- Composer scripts: `analyse` (PHPStan), `test` / `test-coverage` via Pest with `--exclude-group=integration`; `format` uses Pint
- Integration tests: `integration` group, skip when placeholder Cloudinary env is used
- **Adapter internals:** Extracted `CloudinaryPathNormalizer`, `CloudinaryDiskOptions`, `CloudinaryResponseMapper`, `CloudinaryResourceOperations`, and `CloudinaryResponseLogger`; disk options (folder, upload preset, merge options, secure URL preference) are resolved from the Laravel disk config in `FlysystemCloudinaryServiceProvider` with fallback to `config('flysystem-cloudinary.*')` when the adapter is constructed without disk options
- **API:** Added `lastUploadMetadata()`, `lastCopySucceeded()`, and `lastDeleteSucceeded()`; public `$meta`, `$copied`, and `$deleted` remain for backward compatibility
- **Adapter slimming:** `CloudinaryStringUploadSource`, `CloudinaryAdminFolderLocator`, `CloudinaryUrlBuilder`, `CloudinaryListResponseAssembler`, `CloudinaryDiskOptions::uploadOptionsFor()`, and `Concerns\InteractsWithCloudinaryMetadata` further shrink `FlysystemCloudinaryAdapter` and move branching out of the adapter file
- **Security:** `phpunit.xml` no longer ships real Cloudinary API secrets; default env is placeholder-only. Live API integration tests run only when real `CLOUDINARY_*` values are exported (see `tests/cloudinary_integration.php`)
- **Tests:** Dedicated Pest files per collaborator (`CloudinaryResourceOperations`, `CloudinaryResponseMapper`, `CloudinaryResponseLogger`, `CloudinaryUrlBuilder`, `CloudinaryListResponseAssembler`, `CloudinaryAdminFolderLocator`, `CloudinaryDiskOptions`, `FlysystemCloudinaryResponseLog`, `FlysystemCloudinaryServiceProvider`); extra integration cases for missing files, directories, and rename

## 2.0.0 - 2022-11-20

laravel-flysystem v3 upgrade:
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ additional parameters to your url 😉

| Package | PHP | Laravel | Flysystem |
|-----------|-------------|-----------|-------------|
| v12.0 | ^8.2 - ^8.4 | 12.x | 3.25.1 |
| v13.0 | 8.3.*–8.5.* | 13.x | 3.x |
| v12.0 | ^8.2 - ^8.4 | 12.x | 3.x |
| v11.0 | ^8.2 - ^8.3 | 11.x | 3.0 |
| v4.0 | ^8.2 - ^8.3 | 11.x | 3.0 |
| v3.0 | 8.2 | 10.x | 3.0 |
Expand Down Expand Up @@ -59,13 +60,23 @@ configuration:
Add the following environment variables to your `.env` file:

```shell
FILESYSTEM_DRIVER=cloudinary
FILESYSTEM_DISK=cloudinary

CLOUDINARY_CLOUD_NAME=my-cloud-name
CLOUDINARY_API_KEY=my-api-key
CLOUDINARY_API_SECRET=my-api-secret
```

Older Laravel apps may still use `FILESYSTEM_DRIVER`; Laravel 9+ prefers `FILESYSTEM_DISK`.

### Cloudinary folder modes

This adapter lists assets with the Admin API using **public ID `prefix`** and manages folders with **`subFolders` / `create_folder` / `delete_folder`**, which matches **legacy fixed folder mode** and typical public-ID paths. If your Cloudinary product environment uses **dynamic folder mode** only, some behaviours may differ; see [Folder modes](https://cloudinary.com/documentation/folder_modes) and the [Admin API](https://cloudinary.com/documentation/admin_api#folders).

### Continuous integration and integration tests

The test suite includes optional **integration** tests that call the live Cloudinary API. They run only when `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, and `CLOUDINARY_API_SECRET` are set to real values (for example via GitHub Actions secrets). The default `composer test` command excludes the `integration` group; run `vendor/bin/pest` without `--exclude-group` to include them locally.

## 🏗 File extension problem

Let's look at the following example:
Expand Down
29 changes: 17 additions & 12 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codebar-ag/laravel-flysystem-cloudinary",
"description": "Cloudinary Flysystem v1 integration with Laravel",
"description": "Cloudinary Flysystem 3 integration with Laravel",
"keywords": [
"laravel",
"codebar-ag",
Expand All @@ -19,18 +19,18 @@
}
],
"require": {
"php": "8.2.*|8.3.*|8.4.*",
"php": "8.3.*|8.4.*|8.5.*",
"guzzlehttp/guzzle": "^7.8",
"illuminate/contracts": "^12.0",
"illuminate/contracts": "^13.0",
"cloudinary/cloudinary_php": "^3.1",
"nesbot/carbon": "^3.8",
"spatie/laravel-package-tools": "^1.19"
},
"require-dev": {
"laravel/pint": "^1.21",
"larastan/larastan": "^v3.1",
"orchestra/testbench": "^10.0",
"pestphp/pest": "^3.7",
"larastan/larastan": "^3.9",
"orchestra/testbench": "^11.0",
"pestphp/pest": "^4.0",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
Expand All @@ -47,25 +47,30 @@
}
},
"scripts": {
"psalm": "vendor/bin/psalm",
"test": "./vendor/bin/testbench package:test --parallel --no-coverage --exclude-group=Integration",
"test-coverage": "vendor/bin/phpunit --coverage-html coverage --exclude-group=Integration",
"format": "vendor/bin/php-cs-fixer fix --allow-risky=yes"
"analyse": "vendor/bin/phpstan analyse",
"test": "vendor/bin/pest --no-coverage --exclude-group=integration",
"test-coverage": "vendor/bin/pest --coverage --exclude-group=integration",
"format": "vendor/bin/pint"
},
"config": {
"sort-packages": true,
"allow-plugins": {
"composer/package-versions-deprecated": false,
"pestphp/pest-plugin": true,
"phpstan/extension-installer": true
"phpstan/extension-installer": true,
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"extra": {
"branch-alias": {
"dev-main": "13.x-dev"
},
"laravel": {
"providers": [
"CodebarAg\\FlysystemCloudinary\\FlysystemCloudinaryServiceProvider"
]
}
},
"minimum-stability": "stable",
"minimum-stability": "dev",
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting "minimum-stability": "dev" for the package increases the chance that CI/dev installs resolve to dev releases of dependencies (especially under prefer-lowest), which can make the test matrix flaky and harder to reproduce. Unless this is required for a specific dependency, consider keeping minimum-stability at stable and relying on explicit version constraints / @dev flags only where needed.

Suggested change
"minimum-stability": "dev",
"minimum-stability": "stable",

Copilot uses AI. Check for mistakes.
"prefer-stable": true
}
2 changes: 1 addition & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
</logging>
<php>
<env name="APP_KEY" value="base64:F+mHMDBbavrsp/I3WYA5lDSwDJJI/0wQG4eM3csq/lo="/>
<env name="FILESYSTEM_DRIVER" value="cloudinary"/>
<env name="FILESYSTEM_DISK" value="cloudinary"/>
<env name="CLOUDINARY_CLOUD_NAME" value="cloudinary_cloud_name"/>
<env name="CLOUDINARY_API_KEY" value="cloudinary_api_key"/>
<env name="CLOUDINARY_API_SECRET" value="cloudinary_api_secret"/>
Expand Down
38 changes: 38 additions & 0 deletions src/CloudinaryAdminFolderLocator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace CodebarAg\FlysystemCloudinary;

use Cloudinary\Cloudinary;
use Throwable;

final class CloudinaryAdminFolderLocator
{
public function folderExists(Cloudinary $cloudinary, string $prefixedPath): bool
{
$pos = strrpos($prefixedPath, '/');
$needle = $pos === false ? '' : substr($prefixedPath, 0, $pos);

try {
$folders = [];
$response = null;
do {
$response = (array) $cloudinary->adminApi()->subFolders($needle, [
'max_results' => 500,
'next_cursor' => $response['next_cursor'] ?? null,
]);
Comment on lines +16 to +22
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$response starts as null, but the first loop iteration reads $response['next_cursor'] ?? null, which triggers a warning for array offset access on null in PHP 8+. Initializing $response to an empty array (or using a separate $nextCursor variable) avoids noisy warnings and prevents failures in environments that convert warnings to exceptions.

Copilot uses AI. Check for mistakes.

$folders = array_merge($folders, $response['folders'] ?? []);
} while (! empty($response['next_cursor']));

foreach ($folders as $folder) {
if (($folder['path'] ?? '') === $prefixedPath) {
return true;
}
}
} catch (Throwable) {
return false;
}

return false;
}
}
78 changes: 78 additions & 0 deletions src/CloudinaryDiskOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace CodebarAg\FlysystemCloudinary;

use League\Flysystem\Config;

final readonly class CloudinaryDiskOptions
{
/**
* @param array<string, mixed> $globalUploadOptions
*/
public function __construct(
public ?string $folder,
public ?string $uploadPreset,
public array $globalUploadOptions,
public bool $preferSecureUrl,
) {}

/**
* @param array<string, mixed> $diskConfig
*/
public static function fromDiskAndConfig(array $diskConfig): self
{
$publishedOptions = config('flysystem-cloudinary.options', []);

$options = $diskConfig['options'] ?? null;
if (! is_array($options)) {
$options = is_array($publishedOptions) ? $publishedOptions : [];
}

return new self(
folder: self::nullableString($diskConfig['folder'] ?? config('flysystem-cloudinary.folder')),
uploadPreset: self::nullableString($diskConfig['upload_preset'] ?? config('flysystem-cloudinary.upload_preset')),
globalUploadOptions: $options,
preferSecureUrl: (bool) ($diskConfig['secure_url'] ?? config('flysystem-cloudinary.secure_url', true)),
);
}

/**
* Merge base upload API options with disk + Flysystem {@see Config} overrides.
*
* @return array<string, mixed>
*/
public function uploadOptionsFor(string $logicalPublicId, Config $config): array
{
$base = [
'type' => 'upload',
'public_id' => $logicalPublicId,
'invalidate' => true,
'use_filename' => true,
'resource_type' => 'auto',
'unique_filename' => false,
];

$fromDisk = array_filter([
'folder' => $this->folder,
'upload_preset' => $this->uploadPreset,
], static fn ($v) => $v !== null && $v !== '');

$merged = array_merge($base, $fromDisk, $this->globalUploadOptions);

$flyOptions = $config->get('options');
if (is_array($flyOptions) && $flyOptions !== []) {
$merged = array_merge($merged, $flyOptions);
}

return $merged;
}

private static function nullableString(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}

return (string) $value;
}
}
Loading
Loading