PHP code example of citomni / upload

1. Go to this page and download the library: Download citomni/upload library. Choose the download type require.

2. Extract the ZIP file and open the index.php.

3. Add this code to the index.php.
    
        
<?php
require_once('vendor/autoload.php');

/* Start to develop here. Best regards https://php-download.com/ */

    

citomni / upload example snippets



declare(strict_types=1);

return [
	\CitOmni\Image\Boot\Registry::class,
	\CitOmni\Upload\Boot\Registry::class,
];

// config/citomni_cfg.php (illustrative)
return [
	'upload' => [
		'profiles' => [
			'avatar' => [
				'storage'   => 'public',
				'accept'    => ['image/jpeg', 'image/png', 'image/webp'],
				'max_bytes' => 15_000_000,
				'image'     => [
					// citomni/image output specs; quality and similar settings fall back to the image.* defaults.
					'main' => [
						'format' => 'webp',
						'width'  => 1600,
						'height' => 1600,
						'fit'    => 'contain',
					],
					'variants' => [
						'thumb' => [
							'format' => 'webp',
							'width'  => 300,
							'height' => 300,
							'fit'    => 'cover',
						],
					],
				],
			],
		],
	],
];

use CitOmni\Upload\Enum\UploadRejection;
use CitOmni\Upload\Exception\UploadRejectedException;
use CitOmni\Upload\Util\UploadedFiles;

class AvatarController extends BaseController {

	public function update(): void {
		// App-specific: the user and the user's opaque upload token.
		$userId = $this->resolveUserId();
		$uploadToken = $this->resolveUploadToken($userId);

		$file = UploadedFiles::one($_FILES['avatar'] ?? null);
		if ($file === null) {
			$message = $this->app->upload->rejectionMessage(UploadRejection::NoFile);
			// Render the form with $message (app-specific).
			return;
		}

		try {
			$stored = $this->app->upload->storeUpload($file, 'avatar', $uploadToken . '/profile');
		} catch (UploadRejectedException $e) {
			$message = $this->app->upload->rejectionMessage($e);
			// Render the form with $message (app-specific).
			return;
		}

		(new ReplaceAvatar($this->app))->execute($userId, $stored);
		// Redirect (app-specific).
	}
}

'upload' => [
	'storages' => [
		'public' => [
			'root'     => \CITOMNI_APP_PATH . '/public/uploads/u',
			'web_path' => 'uploads/u',
		],
		'private' => [
			'root'     => \CITOMNI_APP_PATH . '/var/storage',
			'web_path' => null,
		],
	],
	'file_mode' => 0644,
	'dir_mode'  => 0755,
	'profiles'  => [],
],

'image' => [
	'main'     => [/* output spec */],
	'variants' => [
		'thumb' => [/* output spec */],
	],
	'options'  => [/* job options */],
],

// config/citomni_cfg.php (illustrative)
return [
	'upload' => [
		'profiles' => [
			'attachment' => [
				'storage'   => 'private',
				'directory' => 'attachments',
				'accept'    => ['application/pdf', 'image/jpeg', 'image/png'],
				'max_bytes' => 20_000_000,
				'shard'     => 1,
			],
			'archived_document' => [
				'storage'   => 'private',
				'directory' => 'archive',
				'accept'    => ['application/pdf', 'application/xml', 'text/xml', 'image/jpeg', 'image/png'],
				'max_bytes' => 25_000_000,
				'shard'     => 2,
				'hash'      => 'sha256',
				'image'     => [
					// Keep the original bytes untouched.
					'main'     => null,
					'variants' => [
						'preview' => ['format' => 'webp', 'width' => 1200],
					],
				],
			],
		],
	],
];

// CitOmni\Upload\Service\Upload, service id "upload"
final class Upload extends BaseService {

	#[\NoDiscard]
	public function storeUpload(array $file, string|array $profile, string $subdir = ''): array {}

	public function checkUpload(array $file, string|array $profile): array {}

	#[\NoDiscard]
	public function storeUploads(array $files, string|array $profile, string $subdir = ''): array {}

	#[\NoDiscard]
	public function storeLocal(string $sourcePath, string|array $profile, string $subdir = '', ?string $originalName = null, bool $move = false): array {}

	public function rejectionMessage(UploadRejectedException|UploadRejection $rejection): string {}

	public function delete(string $storage, string ...$paths): void {}

	public function deleteWithVariants(string $path, string|array $profile): void {}

	public function cleanup(string $storage, string ...$paths): bool {}

	public function cleanupWithVariants(string $path, string|array $profile): bool {}

	public function cleanupStored(array ...$stored): bool {}

	public function variantPath(string $path, string $variant, string|array $profile): string {}

	public function absolutePath(string $storage, string $path): string {}

	public function webPath(string $storage, string $path): string {}
}

$file = UploadedFiles::one($_FILES['document'] ?? null);

if ($file === null) {
	$message = $this->app->upload->rejectionMessage(UploadRejection::NoFile);
	// Render the form with $message (app-specific).
	return;
}

try {
	$checked = $this->app->upload->checkUpload($file, 'document_intake');
} catch (UploadRejectedException $e) {
	$message = $this->app->upload->rejectionMessage($e);
	// Render the form with $message (app-specific).
	return;
}

// ['mime' => 'application/pdf', 'size' => 184321, 'original_name' => 'Faktura 2026.pdf', 'width' => null, 'height' => null]
$stream = \fopen($file['tmp_name'], 'rb');
// Hand $stream and $checked to the application's own storage protocol (app-specific).

$batch = $this->app->upload->storeUploads(UploadedFiles::many($_FILES['attachments'] ?? null), 'attachment', (string)$ownerId);
// ['stored' => list of results, 'rejected' => list of UploadRejectedException]

// CLI command (illustrative): the same Operation an HTTP adapter would use, with another intake.
$stored = $this->app->upload->storeLocal($path, 'archived_document', (string)$ownerId, move: true);
(new ArchiveDocument($this->app))->execute($ownerId, $stored);

[
	'storage'       => 'public',
	'path'          => 'k3f9x2q7/profile/9f86d081884c7d659a2feaa0c55ad015.webp',
	'mime'          => 'image/webp',
	'source_mime'   => 'image/jpeg',
	'size'          => 184321,
	'original_name' => 'IMG_2041.JPG',
	'width'         => 1600,
	'height'        => 1200,
	'hash'          => null,
	'variants'      => [
		'thumb' => [
			'path'   => 'k3f9x2q7/profile/9f86d081884c7d659a2feaa0c55ad015-thumb.webp',
			'mime'   => 'image/webp',
			'size'   => 12044,
			'width'  => 300,
			'height' => 300,
		],
	],
]

$thumb = $this->app->upload->variantPath($row['avatar_path'], 'thumb', 'avatar');
// $baseUrl comes from the HTTP layer (app-specific).
$src = $baseUrl . '/' . $this->app->upload->webPath('public', $thumb);

final class ReplaceAvatar extends BaseOperation {

	/**
	 * Point a user's profile at a newly stored avatar and discard the previous one.
	 *
	 * Behavior:
	 * - Swaps the path in one transaction and receives the previous path.
	 * - On persistence failure, the newly stored files are removed (best effort) and the error bubbles.
	 * - After a successful swap, the previous files are removed (best effort, logged by Upload on failure).
	 *
	 * @param  int                  $userId  Target user.
	 * @param  array<string,mixed>  $stored  Result from Upload::storeUpload() or Upload::storeLocal().
	 * @return array{path:string}  The stored main path.
	 */
	public function execute(int $userId, array $stored): array {
		$repository = new UserProfileRepository($this->app);

		$oldPath = null;
		$swapped = false;
		try {
			$oldPath = $repository->swapAvatarPath($userId, $stored['path']);
			$swapped = true;
		} finally {
			if (!$swapped) {
				$this->app->upload->cleanupStored($stored);
			}
		}

		if ($oldPath !== null) {
			$this->app->upload->cleanupWithVariants($oldPath, 'avatar');
		}

		return ['path' => $stored['path']];
	}
}

final class UserProfileRepository extends BaseRepository {

	public function swapAvatarPath(int $userId, string $newPath): ?string {
		return $this->app->db->transaction(function () use ($userId, $newPath): ?string {
			$old = $this->app->db->fetchValue(
				'SELECT avatar_path FROM user_profiles WHERE user_id = ? FOR UPDATE',
				[$userId]
			);
			$this->app->db->execute(
				'UPDATE user_profiles SET avatar_path = ? WHERE user_id = ?',
				[$newPath, $userId]
			);
			return $old === null ? null : (string)$old;
		});
	}
}

// Controller action body (illustrative), with the imports of the Quick start controller.
$batch = $this->app->upload->storeUploads(UploadedFiles::many($_FILES['attachments'] ?? null), 'attachment', (string)$ownerId);

if ($batch['rejected'] !== []) {
	// All or nothing: the files that were accepted must not linger.
	$this->app->upload->cleanupStored(...$batch['stored']);

	$messages = [];
	foreach ($batch['rejected'] as $e) {
		$messages[] = $e->context['original_name'] . ': ' . $this->app->upload->rejectionMessage($e);
	}
	// Render the form with $messages (app-specific).
	return;
}

$result = (new AddAttachments($this->app))->execute($ownerId, $batch['stored']);

final class AddAttachments extends BaseOperation {

	private const int MAX_FILES = 20;

	/**
	 * Persist stored files for an owner entity within the per-owner limit.
	 *
	 * Behavior:
	 * - Persists all files in one transaction, or none when the limit would be exceeded.
	 * - When nothing is persisted, the stored files are removed (best effort).
	 *
	 * @param  int                        $ownerId      Owning entity.
	 * @param  list<array<string,mixed>>  $storedFiles  Store results, e.g. Upload::storeUploads()['stored'].
	 * @return array{ok:bool, reason:?string, ids:list<int>}  Outcome and the new ids.
	 */
	public function execute(int $ownerId, array $storedFiles): array {
		$repository = new AttachmentRepository($this->app);

		$ids = null;
		try {
			$ids = $repository->insertWithinLimit($ownerId, $storedFiles, self::MAX_FILES);
		} finally {
			if ($ids === null) {
				// Limit exceeded or persistence failed: the stored files must not linger.
				$this->app->upload->cleanupStored(...$storedFiles);
			}
		}

		return $ids === null
			? ['ok' => false, 'reason' => 'limit_exceeded', 'ids' => []]
			: ['ok' => true, 'reason' => null, 'ids' => $ids];
	}
}

// Stops at the first rejection, so nothing after it is processed.
$stored = [];
$complete = false;

try {
	foreach (UploadedFiles::many($_FILES['attachments'] ?? null) as $file) {
		$stored[] = $this->app->upload->storeUpload($file, 'attachment', (string)$ownerId);
	}
	$complete = true;
} catch (UploadRejectedException $e) {
	$message = $this->app->upload->rejectionMessage($e);
	// Render the form with $message (app-specific).
	return;
} finally {
	if (!$complete) {
		// A rejection or a fault part-way: the files stored so far must not linger.
		$this->app->upload->cleanupStored(...$stored);
	}
}

// Controller (illustrative): authorization for $documentId is already enforced.
$row = (new ArchivedDocumentRepository($this->app))->findById($documentId);
$this->app->response->download(
	$this->app->upload->absolutePath('private', $row['storage_path']),
	$row['original_name']
);

// Operation (illustrative): a failed deletion throws, so the record is never marked falsely.
$this->app->upload->deleteWithVariants($row['storage_path'], 'archived_document');
$repository->markPurged($row['id']);

// Controller action body (illustrative), with the imports of the Quick start controller.
$file = UploadedFiles::one($_FILES['document'] ?? null);

if ($file === null) {
	$message = $this->app->upload->rejectionMessage(UploadRejection::NoFile);
	// Render the form with $message (app-specific).
	return;
}

try {
	$checked = $this->app->upload->checkUpload($file, 'document_intake');
} catch (UploadRejectedException $e) {
	$message = $this->app->upload->rejectionMessage($e);
	// Render the form with $message (app-specific).
	return;
}

$stream = \fopen($file['tmp_name'], 'rb');

try {
	// Commits the intent, then writes $stream under a key derived from it (app-specific).
	$result = (new ReceiveDocument($this->app))->execute($ownerId, $checked['original_name'], $checked['mime'], $stream);
} finally {
	\fclose($stream);
}
text
UploadedFiles::one(mixed $entry, int|string ...$path): ?array
UploadedFiles::many(mixed $entry, int|string ...$path): array
StoragePath::validateRelative(string $path): string
StoragePath::join(string ...$parts): string
StoragePath::variant(string $mainPath, string $variant, string $extension): string
OriginalName::sanitize(?string $name): string
MimeMap::canonical(string $mime): string
MimeMap::extension(string $canonicalMime): string
MimeMap::imageFormat(string $canonicalMime): ?ImageFormat
apache
# Example for public/uploads/.htaccess; adapt it to your server. Directives in
# .htaccess ry from the
# PHP handler in the virtual host instead.
<IfModule mod_php.c>
	php_flag engine off
</IfModule>

# Deny dotfiles, including half-written .tmp files.
<FilesMatch "^\.">
	Require all denied
</FilesMatch>

<IfModule mod_headers.c>
	Header always set X-Content-Type-Options "nosniff"
	Header always set Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox"
</IfModule>
text
citomni/
├── kernel/
├── image/
└── upload/
bash
php tests/run.php
bash
php tests/mutation/mutate.php            # All mutants.
php tests/mutation/mutate.php <filter>   # Mutants whose id or label contains <filter>.
php tests/mutation/mutate.php --list     # Validate the definitions and list them.