Building A Blog In Laravel Vapor: Part 5

Html with img alt tag in IDE
Headshot image of author

Sep 24th, 2022 By Taylor Perkins Full Stack Developer

Hello and welcome to possibly our final piece on building a blog application in Laravel Vapor. Today marks the first official Saturday of fall, and it is an absolutely amazing morning. I am really excited about today's discussion, I hope we manage to get to all of it because if we can you should have your very own blog application and able to deploy to vapor automatically on push via Gitlab CI/CD. Before we begin, I wanted to bring up a piece in Laracon I got to watch yesterday. I still haven't seen all of it yet, But I made it to Matt Stauffer's talk and it was definitely quite interesting. I won't be able to disect his entire talk, but I really enjoyed hearing his opinions on subjects such as early abstractions. I myself have always been one to try and anticipate the future, and never duplicate code. So it was kind of an eye opener to hear that someone was totally against such ideas, and I will definitely be taking them under consideration next time I approach this situation. If you haven't, definitely go watch Laracon 2022, there's some really great information and I've learned so much just watching half of it so far. But enough of Laracon, let's focus on the real discussion today. 

Back to where we were with Managing our resources in Filament, we never quite discussed handling featured_image and its associated alt tags for our PostResource or profile_image for UserResource. Fortunately, both will have pretty much exactly the same logic so we'll take a dive into a Posts' featured image and the same can be applied to your UserResource for profile images.

Handling Featured Image Alt Tags With Spatie's Laravel Media Library

In our second post of building this blog, we discussed how we define our single file featured image media collection for our PostResource. And we even defined the image upload field using SpatieMediaLibraryFileUpload and ensuring that's configured for Laravel Vapor. for a refresher, here's what my entire PostResource form method looks like.

1public static function form(Form $form): Form
3 return $form
4 ->schema([
5 TextInput::make('title')
6 ->required()
7 ->maxLength(255)
8 ->afterStateUpdated(function (Closure $get, Closure $set, ?string $state) {
9 if (! $get('is_slug_changed_manually') && filled($state)) {
10 $set('slug', Str::slug($state));
11 }
12 })
13 ->reactive(),
14 TextInput::make('slug')
15 ->required()
16 ->maxLength(255)
17 ->afterStateUpdated(function (Closure $set) {
18 $set('is_slug_changed_manually', true);
19 }),
20 Hidden::make('is_slug_changed_manually')
21 ->default(false)
22 ->dehydrated(false),
23 Textarea::make('description')
24 ->maxLength(16777215),
25 Select::make('status')
26 ->required()
27 ->options([
28 'draft' => 'Draft',
29 'review' => 'In review',
30 'published' => 'Published',
31 ])
32 ->default('draft'),
33 RichEditor::make('content')
34 ->toolbarButtons([
35 'attachFiles',
36 'blockquote',
37 'bold',
38 'bulletList',
39 'codeBlock',
40 'h2',
41 'h3',
42 'italic',
43 'link',
44 'orderedList',
45 'redo',
46 'strike',
47 'undo',
48 'alignment'
49 ])
50 ->fileAttachmentsDisk('s3')
51 ->fileAttachmentsDirectory('attachments')
52 ->fileAttachmentsVisibility('public')
53 ->columnSpan(2),
54 SpatieMediaLibraryFileUpload::make('featured_image')
55 ->disk('s3')
56 ->visibility('public')
57 ->collection('featured_image'),
58 TextInput::make('featured_image_alt')
59 ->maxLength(100),
60 ]);

Now there isn't really anything special there aside from the Vapor configurations. What we're going to be diving into now is how do we apply an image alt tag to that featured image? Well of course there's many ways to solve this, and we're going to start with the simplest and first iteration. Just like with handling our UserResource roles, we'll need to override the parent handleRecordUpdate method and define some custom logic. Fortunately, once again Spatie has saved us loads of time and they have already given us ways to store custom properties such as alt tags to our media. Let's first take a look at a method I defined in our Post model.

2 * Returns whether or not this post has a featured image
3 *
4 * @return boolean
5 */
6public function hasFeaturedImage(): bool
8 return !!$this->getMedia('featured_image')->count();

This is simply just a helpful method that returns a bool, whether or not this post has a featured image already. Of course, we won't want to try and store a custom property to media that doesn't exist on that post. One last thing I want to point out before we proceed, is you'll notice in the PostResource form method I posted above, is the featured_image_alt TextInput field.

2 ->maxLength(100),

Now we're ready to override our handleRecordUpdate. This is simply going to save a custom property, in our case an alt tag, to our featured image media. Since we've defined it as a single file collection, we'll always assume only one media for each post.

1protected function handleRecordUpdate(Model $record, array $data): Model
3 if (isset($data['featured_image_alt']) && $record->hasFeaturedImage()) {
4 $media = $record->getMedia('featured_image')->first();
6 $media->setCustomProperty('alt', $data['featured_image_alt'])->save();
7 }
9 parent::handleRecordUpdate($record, $data);
11 return $record;

We simply have a couple checks to cover our sad path, we don't want to try and store data that is null or store it to media that doesn't exist. Once we pass our sanity checks, lets grab the first (and should be only) Spatie\MediaLibrary\MediaCollections\Models\Media model from our $record. Then from there it's as simple as utilizing the work Spatie already laid out for us. You setCustomProperty, in our case we probably want it called alt, and save the model. Then proceed to call parent handleRecordUpdate and return the $record. That's it! You are now storing a simple alt tag. Of course, this is just a manual way of defining our image alt tag. I have intentions of creating an open-source Laravel package that utilizes AWS Rekognition to automatically define some alt tags for us. But there will be a separate post on that. All we have left is displaying our featured image and alt tag. Considering your personal requirements are likely going to be vastly different, I won't go in depth on how my blade template looks. I will show you some helper's I created and what my img tag looks like though. Here's my Post model attributes. I personally thoroughly enjoy putting as much logic into my models as I can. It makes the end result repeat less logic and sanity checks.

1public function getFeaturedImageAttribute()
3 return $this->getMedia('featured_image')?->first()?->original_url;
6public function getFeaturedImageAltAttribute()
8 return $this->getMedia('featured_image')?->first()?->getCustomProperty('alt');

Last but not least, here's what my img tag looks like to dynamically define the img src and alt tag. This is strictly my featured img block.

2<div class="flex justify-center mb-4">
3 <img class="object-cover h-auto w-full" src="{{ $post->featured_image }}" alt="{{ $post->featured_image_alt }}">

Now let's move on to the fun part. I really enjoy how easy Laravel Vapor is to setup. It can most definitely be a daunting task the first time you take this on though. Just take the engineers approach to things, break each problem down into little bite size pieces and tackle them one at a time. That said, the documentation should get you up and running pretty smoothly. Let's take a look at how I setup my specific project.

Deploying Our Blog With Laravel Vapor

Let's first make sure that we have everything installed already. Vapor comes with a cli that allows you to deploy your project pretty easily. There's so many ways you can start, and I don't want to overwhelm anyone if you take a look at my vapor.yml, you can most definitely solve one problem at a time before you add things such as a custom domain and such. Out of the box, vapor will give you what they call a vanity domain that does not require any domain setup at all. Let's get things installed. 

First things first, always enjoy reading documentation BEFORE I start implementing anything. Especially in untreaded territory, and even then a good refresher is never bad. That said, go ahead and read through the Vapor introduction and absorb as much as you can. Let's get things installed.

1composer require laravel/vapor-cli --update-with-dependencies

Now here's a little helper I created for myself to type less. There's many ways to do this. The Vapor docs recommend creating an alias to the php vendor/bin/vapor command, I did something similar but not how they did it. Here's what my ~/.zshrc looks like, I just added a little function that checks if the sail exists from current project and passes all parameters to the vapor executable.  You may also add this to ~/.bashrc if you don't use zsh. As far as windows goes, I am unsure about how all that should go. I typically find Windows to be a bit too complex for local dev environments and have never messed with it.

1function vapor {
2 if [ ! -f ./vendor/bin/vapor ]; then
3 echo "no vapor in current project" >&2
4 exit 1
5 fi
7 ./vendor/bin/vapor "$@"

Now from your project root you can just call vapor and voila!

Pro tip: If I am throwing too much information at you, and you don't want to learn or deal with shell at all, you can simply replace any calls to vapor with ./vendor/bin/vapor or php vendor/bin/vapor to achieve the same result. I personally have done this for all my projects until just this last month. I tend to ignore excess information and focus on my current learning curve.

If you haven't already, now would be the time to go register for a Laravel Vapor account. I believe they may have even recently launched free sandbox accounts, which would probably be great if this is your first time with Vapor. Next we'll need to login after creating an account. Let's also install vapor-core as well.

1vapor login
2composer require laravel/vapor-core --update-with-dependencies

The login command will be an interactive shell command that asks for the username and password of your account that you may have just recently created. Vapor will need access to you projects. I have also never installed the vapor-ui package. I don't find it necessary, we can do all the vapor things via Vapor's dashboard once you've registered and logged in. Now if you haven't already created an IAM user for your s3 bucket, you'll need to go ahead and do that. The vapor docs define those steps pretty well. Until you get a handle on things, its definitely best to create an IAM user with AdministratorAccess privileges to make things easier for now. Later you can restrict further if you desire. Instructions are available in Vapor docs. You'll want to grab your access key and secret and store them in a safe place. You may already have these if you've already setup an s3 bucket following our previous posts. Next, you'll need to save this AWS account information at your team settings in Vapor. If you haven't already, go ahead and create your first project as well. An easy way to do this is by running the vapor init command. It will ask which AWS account to use and create a vapor.yml file for you. You'll probably want to go ahead and create a database for your specific project as well.

1vapor init
2vapor database LaravelGeek

I believe it starts with just a staging environment, which is good enough to start for sure. Here is what my final vapor.yml looks like, which does include my production environment as well. I would definitely say start with just staging and get things deployed there before even attempting to move on to production.

1id: 33035
2name: LaravelGeek
4 production:
5 memory: 1024
6 cli-memory: 512
7 runtime: 'php-8.1:al2'
8 domain:
9 build:
10 - 'composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev'
11 - 'npm ci && npm run production && rm -rf node_modules'
12 deploy:
13 - 'php artisan migrate --force'
14 database: LaravelGeek
15 storage: name_of_unique_bucket_name
16 staging:
17 memory: 1024
18 cli-memory: 512
19 runtime: 'php-8.1:al2'
20 domain:
21 build:
22 - 'composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev'
23 - 'npm ci && npm run production && rm -rf node_modules'
24 deploy:
25 - 'php artisan migrate --force'
26 database: LaravelGeek
27 storage: name_of_unique_bucket_name

Allow me to break this down a bit. First, don't get discouraged by the domain field in the yml. You can totally ignore that for now and just setup everything else. The most important pieces here is the build and deploy sections. The rest likely came with your initial vapor.yml file, excluding storage and domain. Every build we definitely want to make sure we have an up to date vendor installed. Then we'll also need to ensure we have all of our assets compiled as well. So there's two commands, composer install and npm install/run production. Then you have the deploy section. If you don't add the --force tag, a default ENV variable for Vapor will define the APP_ENV to production. This will make attempting to run migrations not work right, staying on the side of caution. As long as we don't migrate:fresh here we shouldn't need to worry about deleting data. Plus, in this inital stage we probably don't have data we're concerned about yet. Next, notice for database it matches the name of the database created from the Vapor cli in previous steps. And finally, we have the storage field. This bucket will be automatically provisioned, assuming the name of your s3 bucket is globally unique (this means across all s3 buckets in the chosen region). Then once we run the deploy command it will get created if not already. The rest of the fields should probably stay at whatever default they came with, excluding domain since it didn't come in the default yml. I feel the only one left to explain is maybe the runtime, this is just the name of a public AWS resource, which is the runtime container for which your project will run. This has already been created by the vapor team. If you need different versions of php for your runtime, you may find your desired runtime here

Pro tip: for creating just a simple, and cheap AWS resources, you definitely don't want to create a private or serverless database for the time being (serverless requires going private). This increases costs dramatically because it requires provisioning a NAT gateway, which can be costly.

Finally, we should be ready to actually deploy to our first staging environment. Once you have all these things configured, you should be able to easily deploy to your defined staging environment. Take note in my yml I have a production and staging section under environments. These are the names of your environments, and you may create more or less, as well as name them whatever. Keep that in mind, for if your names are different, and you try to run the following command and it fails, it's probably because you don't have a staging environment defined yet. Let's deploy!

1vapor deploy staging

Just like magic, If you setup all your resources right, and your s3 bucket name was unique, you might just have a brand new staging environment. The output of this command will include your vanity URL, which may take a bit of time the first time since CloudFront takes a while to provision (this may actually just take a minute only if using a custom domain). You can go play around now at your vanity domain, make sure everything works and is configured properly. If your blog has custom ENV variables needed for services and such outside the scope of Vapor, (such as a TORCHLIGHT_TOKEN) you'll need to define those in each environments ENV variables section, where the three little dots are in the Vapor dashboard within that specific Vapor Environment.


Once again, it looks like we ran out of time. However, I am going to conclude this blog series here. Narrowing the scope can sometimes be beneficial in programming, and it looks like in blog writing as well :). The topic I wanted to cover and do not have time to this week is automatic vapor deploys with Gitlab CI/CD. However this is a pretty big topic and can be pretty complex, so it may be best anyhow to give it the time it deserves to really break it down and discuss. I personally find myself doing one little tidbit at a time and making sure it works, then moving on to the next. I may have to break it down like that in order for it to make sense. I hope y'all are enjoying my posts, I hope to share the things I've learned over the years and make my attempts to contribute to the Laravel community with tutorials and other coding tips. Thanks for reading. Until next time!

Please sign in or create an account to join the conversation.