Working with images is always a messy adventure. The PhotographRepository#create
method is doing a number of things:
- Creating a new record in the database
- Testing to see if the file upload exists
- Moving the uploaded temp file into a permanent location
The challenge in this scenario is how to handle database problems as well as file I/O problems in a manner that allows the user to try again.
Database operations are inherently safe in that if an INSERT fails on commit, the transaction gets rolled back. File operations are permanent and therefore destructive in nature. You can "undo" a file move operation, but it is not as simple is issuing a "rollback" to the file system. Due to the increased risk of losing information in I/O operations, I would recommend ensuring the database INSERT succeeds first, then attempt to move the file.
While a "photograph" seems to be just one thing, at a code level it is two things: A record in the database and a file on disk. The I/O operation should be placed in its own repository. What exists on a shared drive today might need to move to The Cloud® tomorrow. You actually have a need for two repositories:
PhotographRepository
to handle the CRUD operations in the database
ImageFileRepository
to handle the CRUD operations of storing the file on disk (or in The Cloud®)
The question becomes how to marry the two.
Dependent Repositories
There are times when one repository requires another repository in order to make one complete "transaction" (INSERT a record into a database, move a file on disk). There are two approaches here:
Use Constructor Injection to pass the ImageFileRepository
object into PhotographRepository
$photos = new PhotographRepository(new PDO(...), new ImageFileRepository());
Create a "repository context" object that gives you easy access to all the other repositories in your system
Which approach you take depends on how dependent each repository is on the others. If the PhotographRepository
is the only one that needs another "repository" object, then go for option #1. If you have a number of repository objects that need each other, for example you need to save a parent record in the database and insert child records in other tables, then go for option #2.
Whichever direction you take, you want the file storage decoupled from the meta data stored in the database.
Using Constructor Injection for Dependent Repositories
This is the easiest to implement, but doesn't scale well as your application grows and repositories increasingly become dependent on one another. Part of our goal is to decouple the file storage from the database operations, which is a prime example of how interfaces are beneficial. The PhotographRepository
class will require a second constructor argument:
class PhotographRepository
{
private $connection;
private $images;
public function __construct(PDO $connection, ImageRepository $images) {
$this->connection = $connection;
$this->images = $images;
}
The ImageRepository
object is actually the following interface:
interface ImageRepository
{
public function create($file);
}
The PhotographRepository#create
method gets a refactoring job so the database INSERT occurs first, and then the image is moved:
class PhotographRepository
{
// ...
public function create($file, $caption) {
$imageInfo = new SplFileInfo($file['tmp_name']);
$photo = new Photograph($imageInfo->getFileName(), $file['type'], $file['size'], $caption);
if (!isset($this->statements[__METHOD__])) {
$this->statements = $this->connection->prepare("INSERT INTO photographs (filename, type, size, caption) VALUES (:filename, :type, :size, :caption)");
}
$stmt = $this->statements[__METHOD__];
if ($stmt->execute([':filename'=>$photo->getFilename(), ':type'=>$photo->getType(), ':size'=>$photo->getSize()])) {
$photo->setId($this->connection->lastInsertId());
$stmt->closeCursor();
$this->images->create($file);
return $photo;
} else {
$stmt->closeCursor();
throw new RuntimeException('Failed to save Photograph');
}
}
Two differences:
- The database operation is completed first, then the file operation is performed
- If the database operation fails, an exception gets thrown. being unable to insert into the database is a catastrophic problem, so throw an exception
Now let's implement the ImageRepository
interface to perform the file move operation:
class ImageFileRepository implements ImageRepository
{
private $root;
public function __construct($root) {
if (!file_exists($root)) {
throw new InvalidArgumentException('Root image directory does not exist: ' . $root);
} elseif (is_writable($root)) {
throw new InvalidArgumentException('Root image directory "' . $root . '" is not writable');
}
$this->root = $root;
}
public function create($file) {
$this->ensureValidFile($file);
$targetPath = $this->root . basename($file['name']);
if (file_exists($targetPath)) {
throw new InvalidArgumentException('The file "' . $targetPath . '" already exists');
}
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
throw new RuntimeException('Failed to move upload file from "' . $file['tmp_name'] . '" to "' . $targetPath . '"');
}
return new SplFileInfo($targetPath);
}
private function ensureValidFile($file) {
if (!$this->isValidFile($file)) {
throw new InvalidArgumentException('File is not valid');
} elseif (!is_readable($file['name'])) {
throw new InvalidArgumentException('File is not readable');
} elseif ($file['error'] != 0) {
throw new InvalidArgumentException('File error: ' . $file['error']);
}
}
private function isValidFile($file) {
return $file
&& !empty($file)
&& is_array($file);
}
}
If the any file operation cannot be completed (reads or writes) throw an exception. This is a hard-stop in your application. If your application cannot write to the public/images
directory, fail early and fail loudly. A server admin needs to fix this.
Now it only takes 3 lines of code to create the photograph record and move the image:
$photos = new PhotographRepository(
new PDO(...),
new ImageFileRepository(__DIR__ . '/../public/images/')
);
$caption = $_POST['caption'];
$photo = $photos->create($_FILES['input_name'], $caption);
The first line of code is where you glue the two repositories together. This works well, architecturally, but if you need PhotographRepository
objects in multiple places, you'll have a lot of repeated code. This is where a "Repository Context" comes in handy.
Using a Repository Context
When interdependent relationships between repositories become more commonplace, an object wrapper for your repositories is beneficial:
class RepositoryContext
{
public $photos;
public $images;
public function __construct() {
$this->images = new ImageFileRepository($this, __DIR__ . '/../public/images/');
$this->photos = new PhotographRepository($this, new PDO(...)));
}
}
The key feature is that each repository gets passed a reference to its $context
.
Now ImageFileRepository
becomes:
class ImageFileRepository implements ImageRepository
{
private $context;
private $root;
public function __construct(RepositoryContext $context, $root) {
if (!file_exists($root)) {
throw new InvalidArgumentException('Root image directory does not exist: ' . $root);
} elseif (is_writable($root)) {
throw new InvalidArgumentException('Root image directory "' . $root . '" is not writable');
}
$this->context = $context;
$this->root = $root;
}
And PhotographRepository
becomes:
class PhotographRepository
{
private $context;
private $connection;
private $images;
public function __construct(RepositoryContext $context, PDO $connection) {
$this->context = $context;
$this->connection = $connection;
$this->images = $images;
}
public function create($file, $caption) {
// ...
if ($stmt->execute([':filename'=>$photo->getFilename(), ':type'=>$photo->getType(), ':size'=>$photo->getSize()])) {
$photo->setId($this->connection->lastInsertId());
$stmt->closeCursor();
$this->context->images->create($file);
return $photo;
} else {
$stmt->closeCursor();
throw new RuntimeException('Failed to save Photograph');
}
}
}
After the database insert, we delegate to $this->context->images->create($file);
to access the image file repository. Then it's just a slight change to the code creating the photograph:
$context = new RepositoryContext();
$caption = $_POST['caption'];
$photo = $context->photos->create($_FILES['input_name'], $caption);
Moving Image Files To The Cloud®
Later, when the Business decides to spend money on the next big buzzword, you can write another image repository to store those images on a cloud-based hosting provider:
class CloudImageRepository implements ImageRepository
{
private $url;
private $apiKey;
public function __construct(RepositoryContext $context, $url, $apiKey) {
$this->context = $context;
$this->url = $url;
$this->apiKey = $apiKey;
}
public function create($file, $caption) {
// ...
}
}
And just create a different repository in your context:
class RepositoryContext
{
public $photos;
public $images;
public function __construct() {
$this->images = new CloudImageRepository($this, $url, $apiKey);
$this->photos = new PhotographRepository($this, new PDO(...)));
}
}
Nothing else in your code needs to change.