Skip to main content

This site requires you to update your browser. Your browsing experience maybe affected by not having the most up to date version.

UncleCheese
23rd January 2015

In the previous lesson, we started adding some basic custom fields to our Article pages. We’ll now continue working on getting those pages more integrated with the CMS by allowing the user to attach files and images to them.

Working with Files and Images

Level: Beginner

Duration: 16:15

In this lesson:

Let’s take a quick look at the gaps we’re trying to close. First, we see that the list view of articles has a small image on the left that ostensibly represents a photo that is associated with the article. On the detail view, we have a larger photo. The design doesn’t explicitly dictate whether these are different images or the same image just sized differently, but for the purposes of this tutorial, we’re going to assume the user only has to upload a single image.

The client has also informed us that he will sometimes want to attach a PDF travel brochure to each travel guide, so we’ll need to make a provision in the CMS for a file upload as well.

Common approaches to file storage against database records

If you’ve used other web application frameworks or CMS’s, you may have a few ideas about how files can be persisted to database records. Let’s cover a few common approaches:

1. Save a local file path to a text field on the record

While this solution scores a lot of points for its simplicity, it has serious shortcomings in its longevity. If the file ever moves to a different place, you have to backfill the records that refer to the file with the new path, which makes this a particularly fragile method.

2. Upload the file to a CDN, and save an absolute URI to the file on the record

This is a wonderfully scalable solution, as it leverages the nearly infinite storage limits of cloud hosting. Cloud hosted files tend to be pretty robust and permanent, so it’s unlikely to have the same syncing issues as a local file, but pulling the file remotely hampers our ability to manipulate it, as we’re working over HTTP rather than the local file system.

3. Store the file in a BLOB field

Blobs are a special type database field that store arbitrary binary data, which makes them a great candidate for persisting files. Since it allows you to basically upload directly into the record, this approach has none of the syncing issues that you have with a filesystem, which makes it fairly reliable. The downside is that you can very quickly bloat your database, and it presents a poor separation of concerns. Your database can quickly become repurposed into a de-facto file server if you’re not judicious about how much you’re using it.

All of these techniques make sense in certain contexts, of course, but a framework tries to serve a broad range of implementations, and SilverStripe therefore chooses its own flavour of file storage that balances ease of use, reliability, and scalability.

How SilverStripe handles files

In SilverStripe, syncing the database with the filesystem is an accepted cost, albeit tedious and problematic at times. To minimise this cost, SilverStripe provides a File object, with its own table in the database, that essentially keeps a leger of all the files in the filesystem. The responsibility of keeping it in sync is left entirely to these file records. Any pages or other types of database content that rely on files do not have to worry about this problem. Instead, all they need to store is the ID of the file they need. An ID, as you might know, is considered immutable in the database world, and therefore, no matter what happens to the file -- whether it moves, changes its name, or gets replaced -- the page doesn’t need to be informed. It retains the ID of the file, and can acquire all of its metadata when it needs to.

This doesn’t mean that the overhead of syncing two disparate systems is mitigated by any means. If you were to FTP a bunch of files directly to the server, for instance, the File table would not be informed of the changes. It is therefore necessary to run a task from time to time to keep the database in sync with the filesystem.

Notice the “Sync Files” button in the Files section of the CMS. If you ever do any direct management of the filesystem on your server, be sure to run this task!

Introducing the has_one

So far we’ve been talking about fields that are native to the page type. $Author, $Date, and $Teaser are all stored on the ArticlePage table, and are stored in the $db array. Sometimes fields are stored on foreign table, and all the native table needs is a reference to the ID of the foreign record. The main advantage of this design is that if the foreign content ever changes, all the records who refer to it don’t need to worry about staying up to date.

To relate a page type to a foreign object, you might think all you need is afield in the $db array, cast as an Int, storing the ID of the foreign record. That’s an option, but it’s much more clean to set up that field as a foreign key, so that both the database and the SilverStripe framework will know how to handle it properly.

Let’s create a new private static array in the ArticlePage class called $has_one. This works much like the $db array, only instead of mapping the field names to field types, we’ll map them to the class name (or table name) of the related object. Let’s call our image field “Photo” and our file field “Brochure”.

class ArticlePage extends Page {

    // ...

    private static $has_one = array (
        'Photo' => 'Image',
        'Brochure' => 'File'
    );

    // …
}

Notice that Image has its own class. Appropriately, it’s a subclass of File, but offers its own set of special features, particularly around resizing and resampling.

Run dev/build and notice the new fields that are created. They take on the names PhotoID and BrochureID. SilverStripe automatically appends ID to any $has_one field. After all, the only thing that will be stored here is the ID of a foreign record.

Adding file upload fields

Let’s now add some upload functionality to our getCMSFields function. For file relations, UploadField is the best choice. For tidiness, we’ll put the uploaders on their own tab.

class ArticlePage extends Page {
    // ...
    public function getCMSFields() {
         $fields = parent::getCMSFields();
         // ...

         $fields->addFieldToTab('Root.Attachments', UploadField::create('Photo'));
         $fields->addFieldToTab('Root.Attachments', UploadField::create('Brochure','Travel brochure, optional (PDF only)'));

           return $fields;
    }
}

Log into the CMS and try uploading a few files. Save, and see that the fields hold their state.

This works well, but we can tighten it up a bit. First, giving a written indication of the file type we’re expecting (PDF) is good, but it would be better if we could actually enforce that constraint. After all, we should always expect that if it can be broken, a user will break it.

For this, we’ll tap into the UploadField’s validator.

  public function getCMSFields() {
        $fields = parent::getCMSFields();

        // ..

        $fields->addFieldToTab('Root.Attachments', UploadField::create('Photo'));
        $fields->addFieldToTab('Root.Attachments', $brochure = UploadField::create(
          'Brochure',
          'Travel brochure, optional (PDF only)'
        ));

        $brochure->getValidator()->setAllowedExtensions(array('pdf'));

        return $fields;
  }

Notice that we can use the shortcut of concurrently adding the field to the tab, and assigning it to a variable. This technique is often used when making updates to form fields after instantiation.

Now when we try to upload anything but a PDF to the brochure field, it refuses it, and throws an error.

It would also be nice if the uploader put all the files in a folder of our choosing. By default, everything will end up in assets/Uploads, and that directly can become quite polluted if you don’t stay on top of configuring your upload directories.

We can use setFolderName() on the UploadField to assign a folder, relative to *assets/. If the folder doesn’t exist, it will be created, along with any non-existent ancestors your specify, i.e. “does/not/exist” would create three new folders.

        public function getCMSFields() {
              $fields = parent::getCMSFields();

              //...

              $fields->addFieldToTab('Root.Attachments', $photo = UploadField::create('Photo'));
              $fields->addFieldToTab('Root.Attachments', $brochure = UploadField::create('Brochure','Travel brochure, optional (PDF only)'));

              $photo->setFolderName('travel-photos');
              $brochure
                ->setFolderName('travel-brochures')
                ->getValidator()->setAllowedExtensions(array('pdf'));

              return $fields;
        }

Try uploading a new file, and see that it goes to the appropriate place.

Working with files on the template

Because we declared the file relation as a $has_one, we can access the properties of the File record just as if it’s a native field. SilverStripe will automatically handle all the querying for us.

Let’s make an update to ArticlePage.ss to show a download button for the brochure, if one exists. Below

, add the following:

    <% if $Brochure %>
      <div class="row">
        <div class="col-sm-12"><a class="btn btn-warning btn-block" href="$Brochure.URL"> Download brochure ($Brochure.Extension, $Brochure.Size)</a>
        </div>
      </div>
    <% end_if %>

Calling the property $Brochure, as defined in our $has_one gets us a File object with its own set of properties. We’ll display some of them, but there are many others made available to you, including $Brochure.Filename, $Brochure.Title, and more.

Reload the page and give it a test. You should be able to download your PDF.

The <% with %> block

This file download works great, but we can clean up the template syntax a bit. There are multiple references to properties that we’re getting by traversing the $Brochure object. We can remove all that dot-separated syntax by wrapping the whole thing in a scope block, known as <% with %>.

<% if $Brochure %>
<div class="row">
    <% with $Brochure %>
    <div class="col-sm-12">
        <a href="$URL" class="btn btn-warning btn-block"><i class="fa fa-download"></i> Download brochure [$Extension] ($Size)</a>                  
    </div>
    <% end_with %>
</div>
<% end_if %>

While there’s little, if any, performance gain to this approach, some may find it easier to read. Some developers make more use of scope operators than others. Generally speaking, the more properties you’re getting of the object, the more utility you’ll get out of a <% with %> block.

How image resampling works

You might have noticed that we’ve only chosen to use a single upload field for what appears to be two different photo sizes -- a small one in list view, and a larger one on the detail view. This is because, when dealing with images, we’re only concerned about distinct content. The sizing and resampling of the photos is done on page load through function calls on the template, effectively giving you an unlimited number of different sizes and formats of any given image.

If you’re even remotely concerned about page optimisation, the very thought of resampling images on page load is probably turning your stomach. Fortunately, as we’ll see in a moment, it’s not quite that simple.

Given the image field Photo, we can simply invoke $Photo to create an image tag for the photo, as it was uploaded, in its raw form. Generally speaking, you want to avoid this, as in most use cases, images can be layout-breaking, and we don’t want to blindly trust what a CMS user uploaded (i.e. a 5MB JPEG).

If we invoke a an image resampling function against the photo, we’ll get the same image tag, only to a new version of the image, to the size of our choice.

The following syntax will show the photo at a width of 600 pixels, with unconstrained height, reduced proportionately:

$Photo.SetWidth(600)

It’s effectively the same as reducing a photo by dragging the corner box while holding the shift key in image editing software.

Does this seem like a lot of overhead to add to your templates? Most of the time, it’s almost nothing. Here’s how it works:

SilverStripe generates a sku for the resampled image based on the original filename, the resampling method, and the argument(s) passed to it. For example, given the filename “photo.jpeg”, the above function will generate an image like this:

SetWidth600-photo.jpeg

To avoid collisions and to simplify cleanup, all resampled images are placed in a _resampled/ directory off the directory that contains the source image. In our case, the full path to this image would be:

assets/travel-photos/_resampled/SetWidth600-photo.jpeg

When the SetWidth() method is called, SilverStripe generates the file name, and checks to see if it exists. If it does, it renders the existing image. If not, it creates it, and returns the path to the new file. Either way, you still get your image tag, and the resampling is transparent to you.

The benefit of this approach is that it’s fantastically simple and declarative, but the downside of declarative programming is that it obscures the developer from what is really happening under the hood. In this case, the developer should be aware that the first page load after adding or modifying a resizing function will always be slower than subsequent page loads. How much slower depends on how many images you have. Most of the time, you’ll never notice, but it’s important to be aware that if you’re rendering a lot of new photos (say, 10 or more), you probably want to hit the page once to ensure that all those photos get cached. It is never a good idea to put hundreds of new photos on a page and attempt to resample them all in a single page load, as you’re likely to timeout your PHP process.

There are many image resampling functions that ship with the default install of SilverStripe. It’s also very easy to create your own, which will cover in another tutorial. Here are a few common methods you might find useful:

$Image.SetWidth(width) Resize the image proportionately to fit inside the given width
$Image.SetHeight(height) Resize the image proportionately to fit inside the given height
$Image.SetSize(width, height) Force the image to be a certain width and height. If one dimension falls short, add padding.
$Image.CroppedImage(width, height) Resize to the given width and height, cropping it if necessary to maintain the aspect ratio.

Adding images to the template

Now that we understand how images work, this last step should be pretty straightforward. On ArticleHolder.ss, we see that the photos in list view are about 242x156 pixels. Let’s use CroppedImage for these, as more important that they maintain a uniform size than it is to show all their content.

Replace the placeholder image in the <% loop $Children %> with $Photo.CroppedImage(242,156).

On ArticlePage.ss, the photo is larger, and it’s important that we show all of its content, since this is the detail view. Let’s use SetWidth(750) for this one.

Replace the placeholder image in <div class="blog-main-image" /> with $Photo.SetWidth(750).

Reload the page, and see that your images are displaying properly.

Customising the image tag

As we’ve stated in previous lessons, the situations where SilverStripe does not give you full control over your HTML are few and far between. Image tags are no exception.

Let’s imagine that we need to add a custom class name to our image tag. Right now, our SetWidth() and CroppedImage() functions are outputting the entire string of HTML, so we have no control over that.

The good news is that these methods actually don’t return strings of text. They give us an Image object that contains all of the properties we would expect a file to have, such as $URL, $Extension, $Size, and anything we would expect an image to have, such as $Width, and $Height.

Let’s rewrite those template variables to output custom HTML.

    <img class="my-custom-class" src="$Photo.SetWidth(750).URL" alt="" width="$Photo.SetWidth(750).Width" height="$Photo.SetWidth(750).Height" />

That gets a bit unwieldy, so let’s revisit that <% with %> block that we used earlier to clean things up a bit.

    <% with $Photo.SetWidth(750) %>
    <img class="my-custom-class" src="$URL" alt="" width="$Width" height="”$Height”" />
    <% end_with %>

Questions and Feedback

Hello,

I have watched all your 14 lessons and successfully created and uploaded my website recently. I thank you very much for your very clear and helpful tutorials listed here! Fantastic job!

However now I have a problem and wondering if you are able to help me out.

After published my website, I wanted to make some changes and replace some images files. I have tried uploading the new images onto hosting server but none of their resized files were created in _resampled folder on the server. I am unable to see any of these uploaded images either in CMS (admin) or live website.

I would be grateful if you are able to point me some directions on resolving this problem?

Many thank in advance. Yueji Lyon

by Yueji Lyon at 03:10pm, 1 April 2015

Author

Hi, Yueji,

Glad you're enjoying the tutorials! To answer your question, make sure that your hosting environment has the PHP GD library installed (http://php.net/manual/en/book.image.php). It's fairly common in shared hosting, but other more bare-bones hosting providers won't give it to you by default.

Also, keep in mind that the resampled images won't be created until they're needed, so unless you've accessed a template that needs them, they won't necessarily appear in the filesystem until then.

by UncleCheese at 03:19pm, 1 April 2015

Thanks for such quick reply. I am checking with my hosting support team. P.S. the templates using these images are called. The images doesn't even showup in /admin/page/edit/show/

by Yueji at 03:51pm, 1 April 2015

Hello UncleCheese,

To update with my issue above: I found out that the GD library my hosting server installed doesn't support .jpeg files (which all my image files are) only .gif, .png, etc. The hosting support team is now looking at if they are able to enable this for me. Consider the problem is solved. Many thanks again for your help.

by Yueji at 11:09pm, 1 April 2015

Hi,

Everything is working fine with this tutorial, until I try to add any of these things:

$photo->setFolderName('travel-photos'); $brochure ->setFolderName('travel-brochures') ->getValidator()->setAllowedExtensions(array('pdf'));

Once this code is added to the ArticlePage.php, there is an "Internal Server Error" when I try to open any of the Article Pages in the admin and they will now open.

Any idea why this error might be coming up?

Thanks,

Bob

by Bob at 07:31am, 28 May 2015

Author

Hi, Bob,

"Internal Server Error" is just a generic 500 response. You'll want to crank up your error reporting to get a more verbose error. Make sure your site is in dev mode, and that your PHP configuration has error_reporting turned up and display_errors enabled. Otherwise, you can check your PHP error log, but most people prefer that their dev environments throw verbose errors.

by UncleCheese at 04:03pm, 28 May 2015

Thanks. Figured out my mistake.

by Bob at 06:44am, 29 May 2015

I bet I made the same mistake as you did. I was getting and undefined variable error. Incase anybody else missed it I'll explain here. Initially when he add the upload fields he put this:

$fields->addFieldToTab('Root.Attachments', UploadField::create('Photo'));

When he went to validate the fields for just images he modified the above code first before adding the next line. so now it looks like this:

$fields->addFieldToTab('Root.Attachments', $Photo = UploadField::create('Photo'));

Then he added the line:

$Photo->getValidator()->setAllowedExtensions(array('png', 'gif', 'jpg', 'jpeg'));

You could see him make the edit in the video, but he didn't comment about doing so or why he did it. In the video transcript though he explained it though. Here is what he said:

"Notice that we can use the shortcut of concurrently adding the field to the tab, and assigning it to a variable. This technique is often used when making updates to form fields after instantiation."

Hope this helps for anyone else who makes the same mistake!

by BuckeyeSam89 at 06:33am, 5 June 2015

Darn I messed up the markup somewhere and I can't edit it. Sorry, wish there was a preview box so this mistake could have been avoided.

by BuckeyeSam89 at 06:35am, 5 June 2015

Author

Thanks for pitching in on this thread! I appreciate it. I've cleaned up your comment for you. Edit/Delete comments coming soon.

With regard to the issue, you say I didn't explain the variable assignment, but I'm just reviewing the video, and it seems like I make it very clear at 7:21, and I do dictate that passage from the transcript. Was that easily missed, maybe?

by UncleCheese at 11:48am, 8 June 2015

Yes I guess you did mention it in the video, it just came after you did all the typing. I now noticed the highlighting of the txt during the comment, it's just that it's a simple and quick comment so, naturally, the highlighting lasts a few seconds. This tied with the fact that there was a short pause in talking while typing can lead to the viewer getting lost in thought digesting what was said previously in the video and then easily miss that part. These videos are very well done by the way. Between these and the documentation I'm really loving SilverStripe. Feel free to delete this comment after reading too as it really doesn't offer anything beneficial to the lesson, just feed back for you UncleCheese

by BuckeyeSam89 at 07:09pm, 8 June 2015

Author

Thanks, dude!

by UncleCheese at 11:41am, 9 June 2015

Stuck on something? Have something to share? Don't be shy!

Keep learning!

Adding custom fields to a page

In the previous lesson, we developed a structure for our Travel Guides section that provides a list view of articles, each with a link to their...

The holder/page pattern

In this tutorial we’re going to focus on the Travel Guides section of our website for this topic. As we can see in the designs, there...

Working with Multiple Templates

Typically a site has more than one distinct template used across all pages. The home page, for instance, is likely to have a different layout than...