Laravel 6 Image Uploads to DigitalOcean Spaces

Cost effective drag and drop image upload functionality for your Laravel application.

Feb 11, 2020

DigitalOcean Spaces are a flat $5 a month for 250GB storage & 1TB bandwidth so I was delighted to find that the Flysystem Adapter for AWS SDK V3 I've been using with Laravel to store image files and generate presigned URLs on AWS S3 works flawlessly with DigitalOcean Spaces.

Components:

Installation:

  1. Set up a Spaces resource on DigitalOcean
  2. To use Laravel, you'll need Composer
  3. Install the Laravel Installer: composer global require laravel/installer
  4. laravel new imageUploadDemoApp creates a new Laravel app
  5. cd into your new imageUploadDemoApp directory
  6. Run composer require intervention/image to install Intervention Image (which requires either the GD or Imagick PHP extension)
  7. Run composer require league/flysystem-aws-s3-v3 ~1.0 to install Flysystem
  8. Edit imageUploadDemoApp/app/config/app.php as follows:
    • Add Intervention\Image\ImageServiceProvider::class to the $providers array
    • Add 'Image' => Intervention\Image\Facades\Image::class to the $aliases array
  9. Run php artisan vendor:publish --provider="Intervention\Image\ImageServiceProviderLaravelRecent"
  10. Your app/config/filesystems.php will already have a default entry for s3. Create a new entry for DigitalOcean Spaces like so:
			
	<?php

	return [

		...

		'disks' => [

			...
			# default AWS S3 config
			'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'),
			],

			# create a new DigitalOcean Spaces config
			'do_spaces' => [
				'driver' => 's3',
				'key' => env('DO_SPACES_KEY'), # 
				'secret' => env('DO_SPACES_SECRET'),
				'region' => env('DO_SPACES_REGION'),
				'endpoint' => env('DO_SPACES_ENDPOINT'),
				'bucket' => env('DO_SPACES_RESOURCE'),
			],

			...

			
		
  1. Edit your imageUploadDemoApp/.env file like so:
			

	DO_SPACES_KEY=your_spaces_access_key
	DO_SPACES_SECRET=your_spaces_secret_key (note: this is only displayed once when you first generate a new key)
	DO_SPACES_REGION=nyc3 (Choose the DigitalOcean datacenter region that's closest to you)
	DO_SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
	DO_SPACES_RESOURCE=your_spaces_resource_name (where all your images will go)

			
		
  1. Add two new routes to imageUploadDemoApp/app/routes/web.php like so:
			



	<?php

	...
	Route::get('image/upload', 'ImageController@upload'); # view dropzone image upload form
	Route::post('image/store', 'ImageController@store'); # store an uploaded image
	...

			
		
  1. Run php artisan make:controller ImageController
  2. Open your new controller imageUploadDemoApp/app/Http/Controllers/ImageController.php and edit it like so:
			
	<?php

	namespace App\Http\Controllers;

	use Illuminate\Http\Request;
	use Storage;
	use App\Traits\ImageTrait;

	class ImageController extends Controller
	{
		use ImageTrait;

		# view image upload form
		protected function getImage(Request $request){

			$doSpacesPath = "your/desired/path/on/digitalocean/spaces/";
			$fileNameWithExtension = "WhateverFileNameYouLike.jpg";
			$existingImageURL = $this->getExistingImage($fileNameWithExtension, $doSpacesPath);
			return view('image.upload', ['existingImageURL' => $existingImageURL]);

		}

		# store uploaded image
		public function store(Request $request)
		{
			try {

				# validate the image
				request()->validate([
					'imageUpload' => 'required|image|mimes:jpeg,png,jpg,gif|max:10240', # 10MB
				]);

				$doSpacesPath = "your/desired/path/on/digitalocean/spaces/";
				$preferredFileName = "WhateverFileNameYouLike";
				$temporaryURL = $this->processUploadedImage($request->file('file'), $preferredFileName, $doSpacesPath);

				# return the temporary url via json
				return response()->json([
					'result' => 'success', 
					'imageURL' => $temporaryURL
				]);

			}

			# catch and return errors via json
			catch (Exception $e) {
				return response()->json([
					'result' => 'error', 
					'error' => $e
				]);
			}

		}

	}

			
		
  1. Create a new directory yourLaravelProject/app/Traits
  2. Create a new file yourLaravelProject/app/Traits/ImageTrait.php and edit it like so:
			



	<?php

	namespace App\Traits;

	use Illuminate\Http\Request;
	use Storage;
	use Intervention\Image\ImageManager;

	trait ImageTrait
	{

		# get existing image
		protected function getExistingImage($fileNameWithExtension, $doSpacesPath){
			
			$storage = Storage::disk('do_spaces');
			if($storage->exists($doSpacesPath.$fileNameWithExtension)){
				$client = $storage->getDriver()->getAdapter()->getClient();
				$expiry = "+10 minutes";
				$bucket = config('filesystems.disks.do_spaces.bucket');	# (reads from: app/config/filesystems.php)
				$command = $client->getCommand('GetObject', ['Bucket' => $bucket, 'Key' => $doSpacesPath.$fileNameWithExtension]);
				$execute = $client->createPresignedRequest($command, $expiry);
				$temporaryURL = (string) $execute->getUri();
				return $temporaryURL;
			}
			return null;

		}

		# process image upload
		protected function processUploadedImage($imageUpload, $preferredFileName, $doSpacesPath){

			# set vars
			$ext = $imageUpload->extension(); # get file extension
			$storage = Storage::disk('do_spaces'); # set storage disk to digitalocean spaces
			$client = $storage->getDriver()->getAdapter()->getClient(); # create a storage client

			# You can just store the image without processing it with Intervention if you prefer:
			# $imageUpload->storeAs($doSpacesPath, $preferredFileName.$ext, 'do_spaces');

			# OR you can resize, crop, etc., and store with Intervention
			$manager = new ImageManager(); # create an Intervention ImageManager instance
			$image = $manager->make($imageUpload)->fit(1920, 1080, function ($constraint) { $constraint->upsize(); });
			$storage->put($doSpacesPath.$preferredFileName.$ext, $image->stream());
				
			# get a temporary url for your newly uploaded image
			$bucket = config('filesystems.disks.do_spaces.bucket');	# (reads from: app/config/filesystems.php)
			$expiry = "+10 minutes"; # set the length of time for the temporary URLs to work
			$command = $client->getCommand('GetObject', ['Bucket' => $bucket, 'Key' => $key_path]);
			$execute = $client->createPresignedRequest($command, $expiry); # create the temporary URL
			$temporaryURL = (string) $execute->getUri();

			return $temporaryURL;

		}

	}



			
		
  1. Download dropzone.js and place it here: imageUploadDemoApp/public/js/dropzone.js
  2. Create imageUploadDemoApp/app/views/image/upload.blade.php like so:
			



	<html lang="en">
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<meta name="csrf-token" content="wJR7YgAWyVAG64FHpFCgz1wzoIO1hWHo3mTy2TEF">
		<title>Supernifty Image Upload Demo App</title>
		<style>html,body{background:#3C3B3D;display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-direction:column;-webkit-box-align:center;align-items:center;-webkit-box-pack:center;justify-content:center;width:100vw;height:100vh;}</style>
	</head>
	<body>

		<div class="dropzone" id="imageUpload"></div>
		<input type="hidden" id="existingImageURL" value=" {{$existingImageURL ?? ''}} " />
		<script src="js/dropzone.js"></script>
		<script>

			// dropzone
			Dropzone.autoDiscover = false;

			(function() {

				// image upload
				var dz = new Dropzone('div#imageUpload', {
					paramName: 'imageUpload',
					url: 'image/store',
					// add the csrf-token in the <head> tag above to the request headers
					headers: { 'X-CSRF-TOKEN': document.head.querySelector('meta[name="csrf-token"]').content },
					maxFiles: 1,
					maxFilesize: 12,
					thumbnailWidth: 1920,
					thumbnailHeight: 1080,
			    	acceptedFiles: ".jpeg,.jpg,.png,.gif",
					addRemoveLinks: false,
					dictRemoveFile: "Delete",
					dictDefaultMessage: 'Drop a .PNG, .JPG or .GIF here...',
					timeout: 50000,

					// create thumbnail of (possible) existing image on server
					init: function () {
						
						// get existing image URL from hidden form element in Laravel blade:
						var existingImageURL = document.getElementById('existingImageURL').value;
						if(existingImageURL){
							var re = /(?:\.([^.]+))?$/;
							var ext = re.exec(existingImageURL)[0].toLowerCase();
							var thumbnailName = 'thumbnail.' + ext;
							let mime = null;
							if(ext == '.png'){ mime = "image/png"; }
							else if(ext == '.gif'){ mime = "image/gif"; }
							else if(ext == '.jpg' || ext == '.jpeg'){ mime = "image/jpeg"; }
							if(mime){
								var ff = { name: thumbnailName, size: 12345, type: mime, serverID: thumbnailName, accepted: true };
								this.options.addedfile.call(this, ff);
								this.options.thumbnail.call(this, ff, existingImageURL);
								this.emit("success", ff);
								this.emit("complete", ff);
								this.files.push(ff);
								ff.previewElement.classList.add('dz-success');
								ff.previewElement.classList.add('dz-complete');
							}
			 			}
					},

					// work with response data
					success: function(file, response){
						if(response){
							console.log('response.imageURL: ', response.imageURL);
							// do whatever
						}
					},

					// troubleshoot errors
					error: function(file, response){
						if(response){
							console.log('response', response);
							return false;
						}
					}

				});

				// do something after file is added but before upload begins
				dz.on('addedfile', function (file, response) {

					// allow only one image at a time
					if(this.files.length > 1) {
						this.removeFile(this.files[0]);
					}

				});

				// if you need to send additional data with the file upload
				dz.on('sending', function(file, xhr, formData){
					
					// standard inputs
					// formData.append('userID', document.getElementById('userID').value);
					
					// checkbox
					// if(document.getElementById('acceptedTerms').checked == true){
					//	formData.append('acceptedTerms', document.getElementById('acceptedTerms').value);
					// }

				});

				// upload complete
				dz.on('complete', function (file, response) { 
					// do something after the whole upload process is complete
				});
				
			})();

		</script>

	</body>
	</html>