Introduction

Digital Ocean Spaces is a service much like AWS S3 that allows users to save files. Spaces uses an API that mirrors that of S3 so if you were already using S3, the transition will be seamless. But you may ask, why use Spaces when S3 is also available? That is where an important feature in Digital Ocean comes to play.

A feature that makes the difference: Digital Ocean CDN

CDN stands for "Content delivery network," and as its name states, it is a network of servers that are distributed geographically, which's purpose is to ensure high performance when delivering content to users across the world. This is where Spaces differentiates itself from S3; it has the same API, but it is also linked to Digital Ocean's CDN service, which allows files that are saved in Spaces to be automatically cached in a CDN when uploaded, thus improving performance when saving things like images that will be accessed frequently.

Setting the project up

As the title of this post states, we are going to use Laravel as a means of handling our backend and interacting with Spaces.

# using the laravel installer, we create a new project
# for this demonstration
$ laravel new do-spaces-example

# After that we do a composer install and we are ready to go
$ composer install

Storage driver

Out of the box, Laravel provides support for S3 and has a driver ready for that same use. Since Spaces mimics the S3 API, we can create a configuration for it using the S3 driver but providing different variable values.

To do this, we go to our config folder, and inside filesystems.php in the 'disks' array, we duplicate the s3 config. After that, we have to change the environment variable it uses to our Spaces specific ones, as well as adding a CDN endpoint value and a folder value that we will use later down the line.

    ...
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
        'url' => env('AWS_URL'),
        'endpoint' => env('AWS_ENDPOINT'),
        'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
    ],

    'do' => [
        'driver' => 's3',
        'key' => env('DO_ACCESS_KEY_ID'),
        'secret' => env('DO_SECRET_ACCESS_KEY'),
        'region' => env('DO_DEFAULT_REGION'),
        'bucket' => env('DO_BUCKET'),
        'folder' => env('DO_FOLDER'),
        'cdn_endpoint' => env('DO_CDN_ENDPOINT'),
        'url' => env('DO_URL'),
        'endpoint' => env('DO_ENDPOINT'),
        'use_path_style_endpoint' => env('DO_USE_PATH_STYLE_ENDPOINT', false),
    ],
    ...

Then we add the corresponding variables in our .env file:

DO_ACCESS_KEY_ID={Your-Key}
DO_SECRET_ACCESS_KEY={Your-Secret}
DO_DEFAULT_REGION={Your-region}
DO_BUCKET={Your-bucket}
DO_FOLDER={The-folder-inside-your-bucket}
DO_CDN_ENDPOINT=https://api.digitalocean.com/v2/cdn/endpoints/{Your-CDN-ID}
DO_ENDPOINT=https://{Your-region}.digitaloceanspaces.com/
DO_USE_PATH_STYLE_ENDPOINT=false

Getting your CDN ID

As you might have seen when setting up your environment file, you need a CDN ID so let's see how to get it. You might have searched for it on the Spaces documentation, but sure enough, it isn't there. Wondering why? The reason is that, as mentioned previously, the Spaces API is designed to copy the S3 API which does not include a CDN. The CDN is part of the larger Digital Ocean API, so its documentation is included there and not in Spaces.

Finally, before we get to how to retrieve it, you might wonder why we need a CDN ID in the first place if the connection between Spaces and the CDN is done automatically on upload. That last word is the keyword: on upload. When we upload a new file to our space, the CDN is refreshed automatically but when we update or delete this file, the action is performed in the Space but not reflected on the CDN. For that to happen, we have to purge the CDN cache for that file specifically, and only then our changes will be applied to our CDN. Achieving this is simple, though a delete request to the Digital Ocean API CDN endpoint is enough and that's why our delivery network's identifier is needed.

To get it, just perform a curl request to list your endpoints and grab the corresponding ID from the return array:

$ curl -X GET -H "Content-Type: application/json" \
    -H "Authorization: Bearer $API_TOKEN" \
    "https://api.digitalocean.com/v2/cdn/endpoints"

Coding a CDN service

Before we get to manipulating files, let's make sure we have a simple interface to purge our CDN cache.

Firstly, let's create an interface for the rest of our app to interact with. We will create a CdnService interface who's only method will be purge, which it receives the file name of the file to purge.

<?php

namespace App\Services;

interface CdnService
{
    public function purge($fileName);
}

After that, we can define an implementation for this interface that holds Digital Ocean specific logic. As mentioned before, a simple http delete request will suffice. To do that we will use the included HTTP facade from Laravel. This facade is a wrapper for Laravel's included Guzzle Http client that allows us to perform simple requests from within our Laravel application.

For our request, we just need our file path composed from the folder we specified in our config file, together with the file name that was given as a parameter. With that in hand, we make a request using the Http facade and the asJson method to specify we are sending JSON data to the endpoint for our CDN's cache

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;

class DOCdnService implements CdnService
{

    public function purge($fileName)
    {
        $folder = config('filesystems.do.folder');
        Http::asJson()->delete(
            config('filesystems.do.cdn_endpoint') . '/cache',
            [
                'files' => ["{$folder}/{$fileName}"],
            ]
        );
    }
}

With all of this done, the last step before being ready to use our newly created service is to bind the service interface to our Digital Ocean implementation inside the AppServiceProvider. For that, we go to AppServiceProvider.php inside the app's Provider folder and then inside the boot method we add the following line:

$this->app->bind(CdnService::class, DOCdnService::class);

This way, each time the app requests an instantiation of the CdnService interface, a DOCdnService implementation will be provided. If you are interested in learning more about this technique, it is called Dependency Injection and it's a really handy method for decoupling an interface you want to use from a particular implementation.

Creating a controller to handle Spaces logic

Using artisan, we will create a DOSpacesController that will be in charge of calling the correct logic and services so as to use Digital Ocean. Our first step is injecting the service we created earlier as an attribute to be used later.

 class DoSpacesController extends Controller
{
    private $cdnService;

    public function __construct(CdnService $cdnService)
    {
        $this->cdnService = $cdnService;
    }
    .
    .
    .
}

Uploading a file

For this first operation, we will create a store method that receives a DigitalOceanStoreRequest, stores our file, and then returns a 200 ok response. For this example we will be storing an image since it is one of the most common uses for this type of service, but feel free to adapt this example if you need to store different types of files, since the only change you have to make is to the request validation logic.

public function store(DigitalOceanStoreRequest $request)
{
    $file = $request->asFile('doctorProfileImageFile');
    $fileName = (string) Str::uuid();
    $folder = config('filesystems.disks.do.folder');

    Storage::disk('do')->put(
        "{$folder}/{$fileName}",
        file_get_contents($file)
    );

    return response()->json(['message' => 'File uploaded'], 200);
}

And DigitalOceanStoreRequest is like so:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class DigitalOceanStoreRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'doctorProfileImageFile' => 'required|image|max:2048',
        ];
    }
}

Deleting a file

Deleting a file is where our CDN service starts to appear since when deleting a file from Spaces, we also need to purge that file's cache in the CDN. Similar to the store method, the delete method receives a DigitalOceanDeleteRequest and then uses the Storage facade to delete the file using the 'do' config. However, the difference is in that after performing that operation, we also need to call the purge method of our CdnService.

public function delete(DigitalOceanDeleteRequest $request)
{
    $fileName = $request->validated()['doctorProfileImageFileName'];
    $folder = config('filesystems.disks.do.folder');

    Storage::disk('do')->delete("{$folder}/{$fileName}");
    $this->cdnService->purge($fileName);

    return response()->json(['message' => 'File deleted'], 200);
}

Note that this time what our request validates is a file name attribute:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class DigitalOceanDeleteRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'doctorProfileImageFileName' => 'required|string',
        ];
    }
}

Updating a file

Finally, updating a file represents the combination of all we've seen so far. We have to make a put request to the Spaces API and then delete the CDNs cache so the changes can be seen for those using the service.

public function update(DigitalOceanUpdateRequest $request)
{
    $file = $request->asFile('doctorProfileImageFile');
    $fileName = $request->validated()['doctorProfileImageFileName'];
    $folder = config('filesystems.disks.do.folder');

    Storage::disk('do')->put(
        "{$folder}/{$fileName}",
        file_get_contents($file)
    );
    $this->cdnService->purge($fileName);

    return response()->json(['message' => 'File updated'], 200);
}

Consequently, our request will be a mixture of the one for store and delete:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class DigitalOceanUpdateRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'doctorProfileImageFile' => 'required|image|max:2048',
            'doctorProfileImageFileName' => 'required|string',
        ];
    }
}

Bonus: Making our upload from the frontend

A common scenario you might face is uploading files through the frontend instead of the backend. The process is similar however it differs in that our credentials should not be stored in our client but rather in our server. To achieve this, the client has to request a signed uri from the server and then perform the request to Spaces using that uri.

Controller

Inside of our DOSpacesController we have to create a method that will be in charge of returning this signed uri.  All we are going to do is form the path to the file using the folder from our config and then we are going to ask the File facade to provide us with a uri for that path we created.

public function sign(DigitalOceanSignRequest $request)
{
    $folder = config('filesystems.disks.do.folder');
    $fileName = $request->validated()["doctorProfileImageFileName"];
    $filePath = $folder . '/' . $fileName;
    assert(env('FILESYSTEM_DRIVER') == 'do');

    return File::getSignedUri($filePath);
}

Extending the File Facade

As you may know, getSignedUri is not an included method in that facade. In order to have a cleaner code, we will extend the File facade to include this new method. For this, inside our AppServiceProvider.php, we will add a macro for the Filesystem class called getSignedUri. This new macro will get the current adapter, it's client and bucket and then will generate a PutObject command. This command will be used to tell the client to create a pre-signed request from which we will take the uri.

Filesystem::macro('getSignedUri', function ($filePath) {
    /** @phpstan-ignore-next-line */
    $adapter = Storage::getAdapter();
    $client = $adapter->getClient();
    $bucket = $adapter->getBucket();

    $cmd = $client->getCommand('PutObject', [
        'Bucket' => $bucket,
        'Key' => $filePath,
        'ACL' => 'public-read',
    ]);

    $signedRequest = $client->createPresignedRequest($cmd, '+20 minutes');

    return (string) $signedRequest->getUri();
});

Conclusion

Digital Ocean Spaces is a powerful service that offers an S3 like API with the added benefit of a connection to the Digital Ocean CDN service. All that can be integrated seamlessly to your Laravel 8 application using the included features such as the S3 driver provided with the built in integration of the Flysystem package.