How I Built the PHP Uploadcare Transformations Package
The preface
I recently started developing open source packages and one of the first packages I’ve built is the framework agnostic PHP package PHP Uploadcare Transformations.
I've been working on the package myself for a few months at the time of writing this blog post and it's almost ready for its first release. Every spare hour I have besides the projects we're already working on I used to build this package. The package is able to provide a fluent syntax for generating urls used for Uploadcare's image transformations.
The idea
At Vormkracht10 we mainly use Uploadcare as our infrastructure for images. It offers great abilities in transforming images by simply adding the transformations you want into the url. We built a few methods into our own CMS to handle the different transformations for us. But wouldn't it be nice if someone else could use it as well?
For those who don't know Uploadcare yet, it is a powerful CDN with a lot of possibilities. Besides providing an upload widget, you can also apply transformations on the images you want to retrieve from the CDN. You can do this by creating a url with those transformations in it. The following url returns an image that is centered and cropped with an width of 2000px and a height of 1325px:
<img src="https://ucarecdn.com/<uuid>/crop/2000x1325/center/" alt="">
Building the base class
To start I used the PHP package skeleton from Spatie. Our base class needs to set some parameters in its constructor: a UUID to get the file from Uploadcare and an optional CDN url. The default will be https://ucarecdn.com as it is the default url used when retrieving files from Uploadcare.
Next we want some methods to generate the dynamic transformation url for us. This will be based on the transformation methods we apply to this base class. These will be the methods and its purposes:
getUrl()
will pass our base url and UUID to theapplyTransformations()
method. The result will be used in this method and needs to pass through some additional checks to make sure the url that gets returned is built up correctly.applyTransformations()
needs an url as a parameter to modify it in this method. The method checks which transformation methods are called by using the TransformationsFinder class (more about this class later). In short, that class returns the actual transformation class so we can call thegenerateUrl()
method from that class. Based on the values we passed to the transformation methods the url we got as parameter gets the additional part needed to modify the image per transformation.filename()
gives us the possibility to pass a filename which should be shown to the user. This is a functionality from Uploadcare.__toString()
is a magic PHP method which calls thegetUrl()
method. This method is automatically used when we want to use the outcome of our UploadcareTransformation package as a string (e.g. assigning it to a string parameter).
This is the full UploadcareTransformation class at the moment:
<?php
namespace Vormkracht10\UploadcareTransformations;
use Vormkracht10\UploadcareTransformations\Transformations\TransformationsFinder;
class UploadcareTransformation extends Transformations
{
protected string $uuid;
protected array $transformations = [];
protected string $url;
protected string $baseUrl;
protected ?string $filename = null;
public function __construct(string $uuid, string $cdnUrl = 'https://ucarecdn.com/')
{
$this->uuid = $uuid;
$this->baseUrl = $cdnUrl;
}
public function filename(string $filename)
{
$this->filename = $filename;
return $this;
}
public function getUrl(): string
{
$url = $this->applyTransformations($this->baseUrl . $this->uuid . '/');
// Check if url contains one of the following strings: 'blur_region', 'enhance', 'filter', 'zoom_objects'
// because these transformations won't work if they do not contain the preview transformation as well.
if (str_contains($url, 'blur_region') ||
str_contains($url, 'enhance') ||
str_contains($url, 'filter') ||
str_contains($url, 'zoom_objects')
) {
// Check if url contains 'resize', 'scale_crop' or 'preview'. If not add, add 'preview' to the url.
// By using 'preview' the image will not be changed and produce the biggest possible image.
if (! str_contains($url, 'preview') ||
! str_contains($url, 'scale_crop') ||
! str_contains($url, 'resize')) {
$url .= '-/preview/';
}
}
if (! str_ends_with($url, '/')) {
$url .= '/';
}
if ($this->filename) {
$url = rtrim($url, '/') . '/' . $this->filename;
}
return $url;
}
public function __toString()
{
return $this->getUrl();
}
public function applyTransformations(string $url): string
{
$transformations = TransformationsFinder::for($this->transformations);
foreach ($transformations as $transformation) {
$url = $transformation['class']::generateUrl($url, $transformation['values']);
}
return $url;
}
}
Generating the transformation url
You may ask yourself where are those earlier mentioned transformation methods and how does this class know which methods are being used? Our base class extends the Transformations class which contains all these methods. At the moment of writing this blog post this class contains 28 transformations. When one of these methods is called you need (in most cases) to pass some parameters. The method adds a new key to our transformations array. This array is defined in our base class. It then adds some key/value pairs which are needed later to build up our url. At the end of the method we return $this
. By returning the class itself the user is able to chain multiple methods. This is what a method in our Transformations class looks like:
<?php
namespace Vormkracht10\UploadcareTransformations;
// ...
use Vormkracht10\UploadcareTransformations\Transformations\Crop;
// ...
class Transformations
{
// ...
public function crop(int|string $width, int|string $height, int|string $offsetX = null, int|string $offsetY = null): self
{
$this->transformations['crop'] = Crop::transform($width, $height, $offsetX, $offsetY);
return $this;
}
// ...
}
As mentioned before, we eventually loop through all the chained methods with our TransformationsFinder class which we use in the applyTransformations()
method. Let's take a look at this class. For each transformation I defined its name in a constant. I then created a getTransformation()
method that pairs this constant with the actual class belonging to the transformation. Next I created the for()
method which gets all needed classes based on our $transformations
array.
<?php
namespace Vormkracht10\UploadcareTransformations\Transformations;
class TransformationsFinder
{
public const CROP = 'crop';
// ...
public static function getTransformation($key)
{
$transformations = [
self::CROP => CROP::class,
// ...
];
return $transformations[$key] ?? null;
}
public static function for(array $transformations)
{
$classes = [];
$keys = array_keys($transformations);
foreach ($keys as $transformation) {
$classes[$transformation] = [
'class' => self::getTransformation($transformation),
'values' => $transformations[$transformation],
];
}
return $classes;
}
}
To understand how we get the values and later use the generateUrl()
method we need to take a look at one of the actual Transformation classes.
<?php
namespace Vormkracht10\UploadcareTransformations\Transformations;
use Vormkracht10\UploadcareTransformations\Traits\Validations;
use Vormkracht10\UploadcareTransformations\Transformations\Enums\Offset;
use Vormkracht10\UploadcareTransformations\Transformations\Interfaces\TransformationInterface;
class Crop implements TransformationInterface
{
use Validations;
public const WIDTH = 'width';
public const HEIGHT = 'height';
public const OFFSET_X = 'offset_x';
public const OFFSET_Y = 'offset_y';
public const ALIGN = 'align';
public static function transform(...$args): array
{
$width = $args[0];
$height = $args[1];
$offsetX = $args[2] ?? null;
$offsetY = $args[3] ?? null;
if (is_string($width) && ! self::validate('width', $width)) {
throw new \InvalidArgumentException('Invalid width percentage');
}
if (is_string($height) && ! self::validate('height', $height)) {
throw new \InvalidArgumentException('Invalid height percentage');
}
if ($offsetX && ! self::validate('offset_x', $offsetX)) {
throw new \InvalidArgumentException('Invalid offset X');
}
if ($offsetY && ! self::validate('offset_y', $offsetY)) {
throw new \InvalidArgumentException('Invalid offset Y');
}
if (isset($offsetX) && Offset::tryFrom($offsetX)) {
return [
self::WIDTH => $width,
self::HEIGHT => $height,
self::ALIGN => $offsetX,
];
}
return [
self::WIDTH => $width,
self::HEIGHT => $height,
self::OFFSET_X => $offsetX,
self::OFFSET_Y => $offsetY,
];
}
public static function validate(string $key, ...$args): bool
{
$value = $args[0];
if ($key === self::OFFSET_X && is_string($value)) {
return Offset::tryFrom($value) || self::isValidPercentage($value);
}
if ($key === self::OFFSET_X && is_int($value) || $key === self::OFFSET_Y && is_int($value)) {
return $value >= 0;
}
if ($key === self::OFFSET_Y && is_string($value) || $key === self::WIDTH || $key === self::HEIGHT) {
return self::isValidPercentage($value);
}
return false;
}
public static function generateUrl(string $url, array $values): string
{
if (isset($values['align'])) {
// -/crop/:dimensions/:alignment/
$url .= '-/crop/' . $values['width'] . 'x' . $values['height'] . '/' . $values['align'] . '/';
} elseif (isset($values['offset_x']) && isset($values['offset_y'])) {
// -/crop/:dimensions/:alignment/
$url .= '-/crop/' . $values['width'] . 'x' . $values['height'] . '/' . $values['offset_x'] . ',' . $values['offset_y'] . '/';
} else {
// -/crop/:dimensions/:alignment/
$url .= '-/crop/' . $values['width'] . 'x' . $values['height'] . '/';
}
return $url;
}
}
Each transformation class contains the same methods. This is defined in the TransformationInterface which is implemented by each transformation class:
<?php
namespace Vormkracht10\UploadcareTransformations\Transformations\Interfaces;
interface TransformationInterface
{
public static function validate(string $key, ...$args): ?bool;
public static function transform(...$args): array;
public static function generateUrl(string $url, array $values): string;
}
The first method you'll probably see is the transform method. In this method we get the parameters from the arguments array. We then validate the values when needed. After the values are validated we return an array with the parameters like for example width and height and their values as key/value pairs. We later use these values to generate the url.
One of the other methods is the validation method. In this method we validate the parameters to make sure we won't get an Uploadcare error. As you can see in the Crop class. We can create a custom validation for each value here or validate the value with one of our enum classes by using the tryFrom()
method.
Last but not least is the generateUrl()
method. This method is called from our earlier mentioned applyTransformations()
method in our base class. We pass the url which we want to add the transformation part of this class to and the values we need to generate that part. Based on the given values we build up the url and return it. The url we return might be modified by another transformation class but that depends on how many transformations were used.
Now it is possible to generate a url with transformations like this:
<?php
use Vormkracht10\UploadcareTransformations\UploadcareTransformation;
$uuid = '12a3456b-c789-1234-1de2-3cfa83096e25';
$cdnUrl = 'https://example.com/cdn/';
$transformation = (new UploadcareTransformation($uuid, $cdn));
$url = $transformation->crop(width: 320, height: '50p', offsetX: 'center')->setFill(color: 'ffffff');
echo $url;
// https://example.com/cdn/12a3456b-c789-1234-1de2-3cfa83096e25/-/crop/320x50p/center/-/set_fill/ffffff
Can it be shortened?
The package now provides a cleaner and easier way to generate Uploadcare transformation urls. But to me it still feels like a fair big chunk of code to only add a url to a string. But I solved that by creating a helper function - well actually two - in src/helpers.php
:
<?php
use Vormkracht10\UploadcareTransformations\UploadcareTransformation;
if (! function_exists('uploadcare')) {
function uploadcare(string $uuid, string $cdnUrl = 'https://ucarecdn.com/'): UploadcareTransformation
{
return new UploadcareTransformation($uuid, $cdnUrl);
}
}
if (! function_exists('uc')) {
function uc(string $uuid, string $cdnUrl = 'https://ucarecdn.com/'): UploadcareTransformation
{
return uploadcare($uuid, $cdnUrl);
}
}
Both functions in fact do the exact same thing: return the UploadcareTransformation
instance. By returning just the class - which is now wrapped in a method - we can add the transformations in the same way we did before. We now only have to pass the uuid and an optional CDN url. The benefit is we only have to call 'uc' method instead of creating a new class instance before calling the methods on it. We also don't need to import the UploadcareTransformation class because we will autoload the methods via Composer. We do this by adding this code to our composer.json
in our package:
"autoload": {
// ...
"files": [
"src/helpers.php"
]
},
This way the methods are available through all our code because it is auto loaded by Composer which you can read more about here.
Now we have those helper functions we can create an url as short as this:
$url = uc($uuid)
->crop(width: 320, height: '50p', offsetX: 'center')
->setFill(color: 'ffffff');