Skip to content
Merged
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
12 changes: 6 additions & 6 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 @@ -41,11 +41,11 @@ jobs:
composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update
composer update --${{ matrix.stability }} --prefer-dist --no-interaction

- name: Execute tests
- name: Prepare phpunit config
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
65 changes: 56 additions & 9 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,33 @@ 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`.

## Flysystem 3 and Laravel 13

This package registers a **League Flysystem v3** adapter with Laravel’s `Storage` facade.

- **Exceptions:** On failure, `read` / `readStream` throw `UnableToReadFile`; `copy` throws `UnableToCopyFile`; `delete` throws `UnableToDeleteFile`; `createDirectory` / `deleteDirectory` throw `UnableToCreateDirectory` / `UnableToDeleteDirectory` (see [CHANGELOG](CHANGELOG.md)).
- **`deleteDirectory`:** Cloudinary’s Admin API only deletes **empty** folders. The adapter first destroys **shallow-listed files** under the logical path, then calls `delete_folder`—aligned with the legacy `deleteDir()` behaviour. Listing is shallow; deeply nested trees may need extra steps depending on how assets are organised.
- **`listContents`:** **Shallow** listing only; the `$deep` argument is ignored. Each Admin API `assets` call uses `max_results` => 500 **without** `next_cursor` pagination, so very large prefixes may not return a complete list.
- **`write` / `writeStream` vs `update` / `updateStream`:** Only `write` and `writeStream` set `lastUploadMetadata()` and the public `$meta` property. `update` and `updateStream` return the normalized metadata `array` from the upload (or `false` on failure) but **do not** update `lastUploadMetadata()`—it keeps the value from the last `write` / `writeStream`. Use the return value of `update` / `updateStream` when you need fresh metadata.
- **Other helpers:** `lastCopySucceeded()` and `lastDeleteSucceeded()` (and legacy public `$copied` / `$deleted`) reflect the outcome of the latest `copy` / `delete` calls on this adapter instance.

### 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 Expand Up @@ -143,8 +164,8 @@ use Illuminate\Support\Facades\Storage;

Storage::disk('cloudinary')->getAdapter()->getUrl([
'path' => 'meow',
'options => ['w_250', 'h_250', 'c_thumb',]
);
'options' => ['w_250', 'h_250', 'c_thumb'],
]);
```

You can find all options in
Expand Down Expand Up @@ -180,10 +201,10 @@ You can pass all parameters as an array to the `put` method:
use Illuminate\Support\Facades\Storage;

Storage::disk('cloudinary')->put('meow', $contents, [
'options' [
'options' => [
'notification_url' => 'https://mysite.example.com/notify_endpoint',
'async' => true,
]
],
]);
```

Expand Down Expand Up @@ -260,14 +281,40 @@ return [
];
```

## 🚧 Testing
## 🚧 Testing and static analysis

Run the tests:
Default test run (Pest, **excludes** the `integration` group that calls the live Cloudinary API):

```shell
composer test
```

PHPStan with Larastan:

```shell
composer analyse
```

Pest with code coverage (also excludes `integration`):

```shell
composer test-coverage
```

Apply the project code style (Laravel Pint):

```shell
composer format
```

To run **all** tests including integration tests, use real `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, and `CLOUDINARY_API_SECRET` in the environment, then:

```shell
vendor/bin/pest
```

The same credentials can be supplied as GitHub Actions secrets for CI (see [.github/workflows/run-tests.yml](.github/workflows/run-tests.yml)).

## 📝 Changelog

Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
Expand All @@ -278,7 +325,7 @@ Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details.

## 🧑‍💻 Security Vulnerabilities

Please review [our security policy](../../security/policy) on how to report security vulnerabilities.
Please see [.github/SECURITY.md](.github/SECURITY.md) for how to report security vulnerabilities.

## 🙏 Credits

Expand Down
27 changes: 16 additions & 11 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,19 +47,24 @@
}
},
"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"
Expand Down
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
39 changes: 39 additions & 0 deletions src/CloudinaryAdminFolderLocator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?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 = [];
$nextCursor = null;
do {
$response = (array) $cloudinary->adminApi()->subFolders($needle, [
'max_results' => 500,
'next_cursor' => $nextCursor,
]);

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

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

return false;
}
}
Loading
Loading