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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"test:ci": "vendor/bin/phpunit -c phpunit.xml tests",
"test:ci:coverage": "vendor/bin/phpunit -c phpunit.xml tests --coverage-clover build/logs/clover.xml --coverage-filter src",
"test:ci:coverage-check": "php scripts/check-coverage.php build/logs/clover.xml 80",
"phpstan": "vendor/bin/phpstan analyse src",
"phpstan": "vendor/bin/phpstan analyse src --memory-limit=512M",
"phpcs": "vendor/bin/phpcs",
"phpcbf": "vendor/bin/phpcbf",
"psr": "vendor/bin/phpcs",
Expand Down
29 changes: 28 additions & 1 deletion src/Http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ $allHeaders = Request::headers();

// Uploaded files and server values
$avatar = Request::file('avatar');
$rawAvatar = Request::rawFile('avatar');
$remoteAddress = Request::server('REMOTE_ADDR');
```

Expand Down Expand Up @@ -80,10 +81,36 @@ $rawBody = Request::content();

Useful notes:

- `Request::query('key', $default)`, `Request::post('key', $default)`, `Request::input('key', $default)`, `Request::cookie('key', $default)`, `Request::file('key', $default)`, and `Request::header('name', $default)` all support a default value.
- `Request::query('key', $default)`, `Request::post('key', $default)`, `Request::input('key', $default)`, `Request::cookie('key', $default)`, `Request::file('key', $default)`, `Request::rawFile('key', $default)`, and `Request::header('name', $default)` all support a default value.
- `Request::input()` and `Request::all()` merge query and POST data. When the same key exists in both places, POST wins.
- `Request::file()` returns `UploadedFile` objects (and nested arrays of `UploadedFile` objects for multi-file inputs), while `Request::rawFile()` returns the original PHP `$_FILES` structure.
- `Request::expectsJson()` returns `true` for JSON `Accept` or `Content-Type` headers, AJAX requests, and `/api` routes.

Using the request object directly:

```php
use Myxa\Http\Request;

final class UploadController
{
public function store(Request $request): mixed
{
$avatar = $request->file('avatar');
$rawAvatar = $request->rawFile('avatar');
$photos = $request->file('photos', []);

return [
'path' => $request->path(),
'stored' => $avatar?->store(),
'raw_name' => $rawAvatar['name'] ?? null,
'photo_count' => count($photos),
];
}
}
```

For multi-file inputs, `Request::file('photos')` returns an array of `UploadedFile` objects that you can loop over and store one by one.

## Response

```php
Expand Down
121 changes: 119 additions & 2 deletions src/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Myxa\Http;

use Myxa\Storage\UploadedFile;

/**
* Lightweight HTTP request object for the current app runtime.
*
Expand Down Expand Up @@ -164,11 +166,29 @@ public function cookie(?string $key = null, mixed $default = null): mixed
}

/**
* Return an uploaded file entry or the full files array.
* Return an uploaded file wrapper or the full normalized uploaded files map.
*
* @return array<string, mixed>|mixed
* @return array<string, mixed>|UploadedFile|mixed
*/
public function file(?string $key = null, mixed $default = null): mixed
{
if ($key === null) {
return $this->normalizeUploadedFiles($this->files);
}

if (!array_key_exists($key, $this->files)) {
return $default;
}

return $this->normalizeUploadedFileValue($this->files[$key]);
}

/**
* Return a raw PHP `$_FILES` entry or the full files array.
*
* @return array<string, mixed>|mixed
*/
public function rawFile(?string $key = null, mixed $default = null): mixed
{
return $this->valueFrom($this->files, $key, $default);
}
Expand Down Expand Up @@ -363,6 +383,103 @@ private function valueFrom(array $values, ?string $key, mixed $default): mixed
return $values[$key] ?? $default;
}

/**
* @param array<string, mixed> $files
* @return array<string, mixed>
*/
private function normalizeUploadedFiles(array $files): array
{
$normalized = [];

foreach ($files as $key => $value) {
$normalized[$key] = $this->normalizeUploadedFileValue($value);
}

return $normalized;
}

private function normalizeUploadedFileValue(mixed $value): mixed
{
if (!is_array($value)) {
return $value;
}

if ($this->isUploadedFileArray($value)) {
if ($this->hasNestedUploadedFiles($value)) {
return $this->normalizeNestedUploadedFiles($value);
}

return UploadedFile::fromArray($value);
}

$normalized = [];

foreach ($value as $key => $nestedValue) {
$normalized[$key] = $this->normalizeUploadedFileValue($nestedValue);
}

return $normalized;
}

/**
* @param array{name: mixed, type: mixed, size: mixed, tmp_name: mixed, error: mixed} $fileData
* @return array<int|string, mixed>
*/
private function normalizeNestedUploadedFiles(array $fileData): array
{
$keys = [];

foreach (['name', 'type', 'size', 'tmp_name', 'error'] as $attribute) {
if (!is_array($fileData[$attribute])) {
continue;
}

foreach (array_keys($fileData[$attribute]) as $key) {
$keys[$key] = true;
}
}

$normalized = [];

foreach (array_keys($keys) as $key) {
$normalized[$key] = $this->normalizeUploadedFileValue([
'name' => is_array($fileData['name']) ? ($fileData['name'][$key] ?? null) : null,
'type' => is_array($fileData['type']) ? ($fileData['type'][$key] ?? null) : null,
'size' => is_array($fileData['size']) ? ($fileData['size'][$key] ?? null) : null,
'tmp_name' => is_array($fileData['tmp_name']) ? ($fileData['tmp_name'][$key] ?? null) : null,
'error' => is_array($fileData['error']) ? ($fileData['error'][$key] ?? null) : null,
]);
}

return $normalized;
}

/**
* @param array<string, mixed> $value
*/
private function isUploadedFileArray(array $value): bool
{
$requiredKeys = ['error', 'name', 'size', 'tmp_name', 'type'];

sort($requiredKeys);
$keys = array_keys($value);
sort($keys);

return $keys === $requiredKeys;
}

/**
* @param array{name: mixed, type: mixed, size: mixed, tmp_name: mixed, error: mixed} $value
*/
private function hasNestedUploadedFiles(array $value): bool
{
return is_array($value['name'])
|| is_array($value['type'])
|| is_array($value['size'])
|| is_array($value['tmp_name'])
|| is_array($value['error']);
}

/**
* @param array<string, mixed> $query
*/
Expand Down
58 changes: 58 additions & 0 deletions src/Storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,61 @@ $exists = Storage::exists('avatars/john.txt');

## Upload Files

From a request upload object with an injected request:

```php
use Myxa\Http\Request;

final class ProfileController
{
public function store(Request $request): void
{
$stored = $request->file('avatar')->store();

$stored = $request->file('avatar')->store('avatars/john.png', [
'metadata' => ['user_id' => 42],
]);
}
}
```

Storing multiple uploaded files:

```php
use Myxa\Http\Request;

final class GalleryController
{
public function store(Request $request): array
{
$storedFiles = [];

foreach ($request->file('photos', []) as $index => $photo) {
$storedFiles[] = $photo->store(sprintf('galleries/photo-%d.%s', $index + 1, $photo->extension()));
}

return $storedFiles;
}
}
```

From a request upload object with the facade:

```php
use Myxa\Support\Facades\Request;

$stored = Request::file('avatar')->store();

$stored = Request::file('avatar')->store('avatars/john.png', [
'metadata' => ['user_id' => 42],
]);

$stored = Request::file('avatar')->store(
'avatars/john.png',
storage: 's3',
);
```

With the facade:

```php
Expand All @@ -81,6 +136,9 @@ $stored = $storage->upload(
);
```

`UploadedFile::store()` is the preferred path when you already have a request file object.
`Storage::upload()` remains available as a convenience wrapper for raw `$_FILES` data or facade-style usage.

## Named Drivers

You can register multiple storage drivers and target them by alias.
Expand Down
96 changes: 89 additions & 7 deletions src/Storage/UploadedFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace Myxa\Storage;

use InvalidArgumentException;
use Myxa\Storage\Exceptions\StorageException;
use Myxa\Support\Facades\Storage as StorageFacade;

final class UploadedFile
{
Expand Down Expand Up @@ -193,24 +195,34 @@ public function contents(bool $base64Encode = false): string
}

/**
* Persist the upload into a storage driver.
* Persist the upload into storage.
*
* Supports both styles:
* - store('avatars/photo.png', ['metadata' => [...]])
* - store($storage, 'avatars/photo.png', ['metadata' => [...]])
*
* @param array{name?: string, mime_type?: string, metadata?: array<string, mixed>} $options
*/
public function store(StorageInterface $storage, ?string $location = null, array $options = []): StoredFile
public function store(mixed $location = null, mixed $options = [], mixed $storage = null): StoredFile
{
if (!$this->isValid()) {
throw new StorageException($this->errorMessage());
}

$resolvedLocation = $location ?? self::sanitizeFileName($this->name);
[$resolvedStorage, $resolvedLocation, $resolvedOptions] = $this->resolveStoreArguments(
$location,
$options,
$storage,
);

$resolvedLocation ??= self::sanitizeFileName($this->name);
$resolvedOptions = [
'name' => $options['name'] ?? $this->name,
'mime_type' => $options['mime_type'] ?? $this->type,
'metadata' => $options['metadata'] ?? [],
'name' => $resolvedOptions['name'] ?? $this->name,
'mime_type' => $resolvedOptions['mime_type'] ?? $this->type,
'metadata' => $resolvedOptions['metadata'] ?? [],
];

return $storage->put($resolvedLocation, $this->contents(), $resolvedOptions);
return $resolvedStorage->put($resolvedLocation, $this->contents(), $resolvedOptions);
}

/**
Expand All @@ -226,4 +238,74 @@ private function isValidFileArray(array $fileData): bool

return $keys === $requiredKeys;
}

/**
* @return array{0: StorageInterface, 1: ?string, 2: array<string, mixed>}
*/
private function resolveStoreArguments(mixed $location, mixed $options, mixed $storage): array
{
if ($location instanceof StorageInterface) {
if (is_array($options) && $storage === null) {
return [$location, null, $options];
}

return [
$location,
$this->normalizeNullableLocation($options),
$this->normalizeStoreOptions($storage),
];
}

return [
$this->resolveStorage($storage),
$this->normalizeNullableLocation($location),
$this->normalizeStoreOptions($options),
];
}

private function normalizeNullableLocation(mixed $location): ?string
{
if ($location === null) {
return null;
}

if (!is_string($location)) {
throw new InvalidArgumentException('Upload location must be a string or null.');
}

return $location;
}

/**
* @return array<string, mixed>
*/
private function normalizeStoreOptions(mixed $options): array
{
if ($options === null) {
return [];
}

if (!is_array($options)) {
throw new InvalidArgumentException('Upload options must be an array.');
}

return $options;
}

private function resolveStorage(mixed $storage): StorageInterface
{
if ($storage instanceof StorageInterface) {
return $storage;
}

if ($storage === null) {
return StorageFacade::storage();
}

if (!is_string($storage)) {
throw new InvalidArgumentException('Upload storage must be a storage alias, storage instance, or null.');
}

return StorageFacade::storage($storage);
}
}
Loading
Loading