Making Symfony Work For Us
We should be feeling pretty happy with ourselves right now. We've seemingly significantly simplified our code by using this interface
based approach.
Our tests reliably and reproducibly prove that so long as we provide the expected inputs, we should get the expected outputs / outcomes. This is a nod to the fact that this function is not pure, and is therefore much harder to test.
What we now need to do is create an implementation of our FileInterface
that is compatible with Symfony's view of the world.
We're now relying on receiving a FileInterface
, but as we know, Symfony's FileType
form field type will give us a pesky UploadedFile
instance instead.
We're going to need a way to transform Symfony's UploadedFile
instance into some class that we control.
In order for our system to perform as expected, this new class that we control will also need to implements FileInterface
.
The way I am going to approach this problem is to leverage the options available in Symfony's form component.
One of the concepts available by way of using the form component is that of Data Transformers.
What this will allow us to do is to accept a Symfony UploadedFile
instance, and as part of the form submission, transform this into some class that we control.
We will make sure that this class implements FileInterface
and we should have just nailed our first requirement.
Please welcome your own, your very own, SymfonyUploadedFile
Let's get PhpSpec to provide the needful:
php vendor/bin/phpspec desc AppBundle/File/SymfonyUploadedFile
Specification for AppBundle\File\SymfonyUploadedFile created in /path/to/my/wallpaper/spec/AppBundle/File/SymfonyUploadedFileSpec.php.
And we can immediately run that new spec to make sure our SymfonyUploadedFile
implementation is added for us, too:
php vendor/bin/phpspec run
AppBundle/File/SymfonyUploadedFile
11 - it is initializable
class AppBundle\File\SymfonyUploadedFile does not exist.
85% 14% 7
3 specs
7 examples (6 passed, 1 broken)
80ms
Do you want me to create `AppBundle\File\SymfonyUploadedFile` for you?
[Y/n]
y
Class AppBundle\File\SymfonyUploadedFile created in /Users/Shared/Development/wallpaper/src/AppBundle/File/SymfonyUploadedFile.php.
100% 7
3 specs
7 examples (7 passed)
30ms
Nice.
Open up the spec and let's make some quick changes, based on what we already know:
<?php
// /spec/AppBundle/File/SymfonyUploadedFileSpec.php.
namespace spec\AppBundle\File;
use AppBundle\File\SymfonyUploadedFile;
use AppBundle\File\FileInterface;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
class SymfonyUploadedFileSpec extends ObjectBehavior
{
function it_is_initializable()
{
$this->shouldHaveType(SymfonyUploadedFile::class);
$this->shouldImplement(FileInterface::class);
}
}
And we run this spec, and it creates the file for us, and we need to make sure we go into the newly created class and implements FileInterface
.
But here we hit on a problem.
When implementing the expected methods from FileInterface
on our SymfonyUploadedFile
concept, things get messy:
<?php
namespace AppBundle\File;
use AppBundle\Model\FileInterface;
class SymfonyUploadedFile implements FileInterface
{
public function getExistingFilePath()
{
// TODO: Implement getExistingFilePath() method.
}
public function getNewFilePath()
{
// TODO: Implement getNewFilePath() method.
}
}
Why would a File know anything about how to get its own new path?
The name getExistingFilePath
also doesn't make sense - what else could this path be in the context of a file? It will always have a current path...
This isn't bad. It's really, really good.
Useful knowledge is being revealed to us. This knowledge will help us better model our system.
Let's rethink these method names and update the interface:
<?php
// src/AppBundle/Model/FileInterface.php
namespace AppBundle\Model;
interface FileInterface
{
public function getPathname();
public function getFilename();
}
We weren't overly specific on the implementation for SymfonyUploadedFileSpec
, so there's not test changes to make. Inside the implementation, things now make a little more sense:
<?php
namespace AppBundle\File;
use AppBundle\Model\FileInterface;
class SymfonyUploadedFile implements FileInterface
{
public function getPathname()
{
// TODO: Implement getPathname() method.
}
public function getFilename()
{
// TODO: Implement getFilename() method.
}
}
In order to get access to these two pieces of information, our SymfonyUploadedFile
will need access to Symfony's UploadedFile
.
It would be really nice here to be able to use a constructor to guarantee that we always have a FileInterface
implementation set when working with this class.
Unfortunately, this class needs to interact with Symfony's form component, and that involves us using a setter
.
<?php
// /src/AppBundle/File/SymfonyUploadedFile.php
namespace AppBundle\File;
use AppBundle\Model\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class SymfonyUploadedFile implements FileInterface
{
/**
* @var UploadedFile
*/
private $uploadedFile;
/**
* @return UploadedFile
*/
public function getFile()
{
return $this->uploadedFile;
}
public function setFile(UploadedFile $uploadedFile)
{
$this->uploadedFile = $uploadedFile;
}
public function getPathname()
{
return $this->uploadedFile->getPathname();
}
public function getFilename()
{
return $this->uploadedFile->getClientOriginalName();
}
}
An improvement we might make here would be to try
/ catch
the call to $this->uploadedFile
in our two interface requirement methods.
EasyAdminBundle Custom Upload Form
We are a good way through our task, but still, there is work to be done.
EasyAdminBundle provides us with a way to add an "upload" input. We already have this:
# /app/config/config/easy_admin_bundle.yml
easy_admin:
entities:
Wallpaper:
class: AppBundle\Entity\Wallpaper
form:
fields:
- { property: "file", type: "file", label: "File" }
- "slug"
However, now we have a new problem.
We need a way to add our Data Transformer to our form, in order to intercept the process involved in getting from form submission, to entity we can work with in our code.
Behind the configuration, EasyAdminBundle is managing the creation of the various forms we desire.
We can customise this process. And indeed we shall, by creating a new custom Form Field Type of our own to represent uploads in a way we control.
<?php
// /src/AppBundle/Form/Type/UploadedFileType.php
namespace AppBundle\Form\Type;
use AppBundle\File\SymfonyUploadedFile;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UploadedFileType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('file', FileType::class, [
'multiple' => false,
])
;
}
public function configureOptions(OptionsResolver $optionsResolver)
{
$optionsResolver->setDefaults([
'data_class' => SymfonyUploadedFile::class,
]);
}
}
Our SymfonyUploadedFile
object contains a single property: file
.
We expect to be able to set a file of type UploadedFile
- the Symfony variant:
public function setFile(UploadedFile $uploadedFile)
This tiny object enables us to transfer data in a manner we control, rather than being beholden to Symfony.
To use this new UploadedFileType
in our EasyAdminBundle configuration, we do need to make a change:
# /app/config/config/easy_admin_bundle.yml
easy_admin:
entities:
Wallpaper:
class: AppBundle\Entity\Wallpaper
form:
fields:
- { property: "file", type: 'AppBundle\Form\Type\UploadedFileType' }
- "slug"
What's fairly incredible to me - pretty much every time I write tests like this - is that when we finally come to hook everything together, things tend to work correctly the first time, more often that not.
After all this, we haven't even tried the front end code for a long time now, and we've certainly made our fair share of change.
Yet hooking up this new uploaded file type gets us almost all of the way there:
An exception occurred while executing 'INSERT INTO wallpaper (filename, slug, width, height, category_id) VALUES (?, ?, ?, ?, ?)' with params [null, "some-slug", 11, 11, null]:
SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'filename' cannot be null
Just like when we did things without tests, we still need to bring in some extra data such as the width and height and set the filename, and all that.
That's what we will get on to, in the very next video.