Build File Converting Application with Symfony

November 05, 2024
Written by
Joseph Udonsak
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build File Converting Application with Symfony

Data plays a central role in everyday life. Whether it’s in generating reports, populating databases, exchanging information, or analytics — you are very likely to interact with data in varying formats and to find yourself converting between them.

In this tutorial, I will show you how to build a Symfony application that converts between four popular data formats — CSV, JSON, SQL, and XLSX. In addition to this, I will show you some Symfony features that will allow you to seamlessly add new formats to your application without touching your existing code.

Prerequisites

To follow this tutorial, you will require the following.

  • A basic understanding of and familiarity with PHP and Symfony
  • PHP 8.2 or above
  • Composer globally installed
  • The Symfony CLI
  • A JavaScript package manager (npm will be used in this tutorial)

Create a new Symfony project

Create a new Symfony project and change into the new project directory using the following commands.

symfony new file_converter_application
cd file_converter_application

Next, add the project's dependencies using the following commands.

composer require doctrine encore form mime tales-from-a-dev/flowbite-bundle twig validator yectep/phpspreadsheet-bundle
composer require --dev maker

Docker won’t be used in this tutorial, so press the "n" key when prompted to create a docker.yaml file.

While installing the Flowbite-bundle, you will see the warnings about executing recipes from the "contrib" repository. Press the y key on both occasions.

Here’s what each dependency is for:

  • Doctrine: This package will be used to handle database-related activity
  • Encore: This will be used to compile CSS and JS assets
  • Form: This bundle simplifies the process of building, rendering and managing HTML forms in a Symfony application
  • Flowbite-bundle: This bundle provides a Tailwind CSS-based form theme for your application forms
  • Mime: This bundle provides utilities related to MIME types. In this tutorial, you will use it to get more information on the uploaded file.
  • Maker: This bundle helps with the auto-generation of code associated with controllers, forms, and entities
  • PHPSpreadsheetBundle: This bundle integrates your application with the PhpSpreadsheet productivity library, allowing you to read and create CSV and XLSX files
  • Twig: This bundle allow you to use Twig templating in your application
  • Validator: This bundle helps with validation of your Symfony form

Set the required environment variables

For this project, you will need two environment variables — one for the database URL and, another for the location where converted files should be written to.

Add the following variables to the end of .env located at the root of the project directory.

DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
OUTPUT_FOLDER="%kernel.project_dir%/output"
Be sure to comment out the default DATABASE_URL value set by Doctrine.

Next, create a new folder named output in the root directory of the project. This is the folder referred to in the .env file in OUTPUT_FOLDER.

For this tutorial, SQLite will be used for the database. The database will be saved to a file named data.db in the var folder. Create this file using the following command.

symfony console doctrine:database:create

Create the required entity

Your application will have a single entity named Conversion. This entity will hold relevant information pertaining to the file uploaded for conversion, as well as the file generated on a successful conversion. Create it using the following command.

symfony console make:entity Conversion

You will be prompted with the following question.

New property name (press <return> to stop adding fields):

Here, press Enter to complete the creation process. This process will create an entity class and a repository class for database related activity pertaining to the ConversionResult entity.

Next, open the newly created src/Entity/Conversion.php and update it to match the following code.

<?php

namespace App\Entity;

use App\Repository\ConversionRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ConversionRepository::class)]
class Conversion {

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column]
    private string $uploadedFileName;

    #[ORM\Column]
    private string $uploadedFileSize;

    #[ORM\Column]
    private string $uploadedFileFormat;

    #[ORM\Column]
    private string $convertedFileName;

    #[ORM\Column]
    private string $convertedFileSize;

    #[ORM\Column]
    private string $convertedFileFormat;

    #[ORM\Column]
    private string $nameToSaveAs;

    #[ORM\Column]
    private DateTimeImmutable $conversionDate;

    public function __construct() {
        $this->conversionDate = new DateTimeImmutable;
    }

    public function getId(): ?int {
        return $this->id;
    }

    public function getUploadedFileName(): string {
        return $this->uploadedFileName;
    }

    public function setUploadedFileName(string $uploadedFileName): void {
        $this->uploadedFileName = $uploadedFileName;
    }

    public function getUploadedFileSize(): string {
        return $this->uploadedFileSize;
    }

    public function setUploadedFileSize(int $uploadedFileSize): void {
        $this->uploadedFileSize = $this->readableFileSize($uploadedFileSize);
    }

    private function readableFileSize(int $size): string {
        $i = floor(log($size) / log(1024));
        $sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];

        return sprintf('%.02F', $size / pow(1024, $i)).' '.$sizes[$i];
    }

    public function getUploadedFileFormat(): string {
        return $this->uploadedFileFormat;
    }

    public function setUploadedFileFormat(string $uploadedFileFormat): void {
        $this->uploadedFileFormat = $uploadedFileFormat;
    }

    public function getConvertedFileName(): string {
        return "$this->convertedFileName.$this->convertedFileFormat";
    }

    public function setConvertedFileName(string $convertedFileName): void {
        $this->convertedFileName = $convertedFileName;
    }

    public function getConvertedFileSize(): string {
        return $this->convertedFileSize;
    }

    public function setConvertedFileSize(int $convertedFileSize): void {
        $this->convertedFileSize = $this->readableFileSize($convertedFileSize);
    }

    public function getConvertedFileFormat(): string {
        return $this->convertedFileFormat;
    }

    public function setConvertedFileFormat(string $convertedFileFormat): void {
        $this->convertedFileFormat = $convertedFileFormat;
    }

    public function getNameToSaveAs(): string {
        return $this->nameToSaveAs;
    }

    public function setNameToSaveAs(string $nameToSaveAs): void {
        $this->nameToSaveAs = preg_replace('/\.[A-Za-z]*/', '', $nameToSaveAs);
    }

    public function getConversionDate(): DateTimeImmutable {
        return $this->conversionDate;
    }
}

Apart from the usual getters and setters for the entity, a function named readableFileSize() was declared. It takes the size of a file as an integer and returns the size in a readable format e.g., ‘9.6KB’. This function is used to set the values of $uploadedFileSize and $convertedFileSize as readable strings instead of the raw integer value.

Next, add the function below to src/Repository/ConversionRepository.php (also created by the previous command), to get the list of conversion results in descending order.

public function getConversions(): array {
    return $this->findBy([], ['conversionDate' => 'DESC']);
}

With these changes in place, provision your database using the following commands.

symfony console make:migration
symfony console doctrine:migrations:migrate -n

Create helper services

The next step is to create some services to help with the process of format conversion. These services will fall into three categories as follows:

  • Reader: These services read files in a specified format and create an intermediary object for the converter.
  • Writer: These services write the content of an intermediary object into a file of a particular format.
  • Converter: These services combine the reader and writer for a particular format, giving one the ability to read and write files in that format.

As mentioned at the start of the tutorial, reading, writing, and conversion will be between the CSV, JSON, SQL, and XLSX formats. While each format will have its own converter, the CSV and XLSX formats can use the same reader and writer since the code for reading and writing in those formats is identical.

In the src folder, create a new folder named Helper. This is where the above mentioned services will live.

Next, in the src/Helper folder, create three new folders, named Converter, Reader, and Writer, to hold the respective services.

Create the reader services

In the Reader folder, create a new file named FileContent.php and add the following code to it.

<?php

namespace App\Helper\Reader;

final readonly class FileContent 
{
    /**
     * @throws FileReadException
     */
    public function __construct(
        public array $keys,
        public array $data,
    ) {
        if (empty($this->keys) || empty($this->data)) {
            throw new FileReadException('Empty files are not permitted');
        }
    }
}

This object takes two arrays — one for the data keys, and another for the data itself. If either of these arrays are empty, an exception is thrown.

At the moment the FileReadException class does not exist, so in the Reader folder, create a new file named FileReadException.php. Then, add the following to it.

<?php

namespace App\Helper\Reader;

use Exception;

class FileReadException extends Exception {}

Next, create a new file named AbstractReader.php in the Readerfolder and add the following code to it.

<?php

namespace App\Helper\Reader;

abstract class AbstractReader 
{
    protected array $keys = [];
    protected array $data = [];
    public abstract function read(string $filePath): void;

    /**
     * @throws FileReadException
     */
    public function getContent(): FileContent {
        return new FileContent($this->keys, $this->data);
    }
}

All the readers you will create extend this class and override the read() function based on the peculiarities of the data format.

Create a JSON reader

Next, in the src/Helper/Reader folder, create a new file named JsonReader.php with the following code.

<?php

namespace App\Helper\Reader;

final class JsonReader extends AbstractReader 
{
    /**
     * @throws FileReadException
     */
    public function read(string $filePath): void 
    {
        $json = file_get_contents($filePath);
        $content = json_decode($json, true);
        $this->validateContent($content);
        $this->keys = array_keys($content[0]);
        $this->parseData($content);
    }

    /**
     * @throws FileReadException
     */
    private function validateContent(mixed $content): void 
    {
        if (is_null($content)) {
            throw new FileReadException('Invalid JSON provided');
        }

        if (empty($content)) {
            throw new FileReadException('Invalid JSON provided');
        }
    }

    private function parseData(array $data): void 
    {
        foreach ($data as $datum) {
            $this->data[] = array_values($datum);
        }
    }
}

Create a Spreadsheet reader

Next, in the src/Helper/Reader folder, create a new file named SpreadsheetReader.php with the following code.

<?php

namespace App\Helper\Reader;

use PhpOffice\PhpSpreadsheet\IOFactory;

final class SpreadsheetReader extends AbstractReader 
{
    public function read(string $filePath): void 
    {
        $inputFileType = IOFactory::identify($filePath);
        // Create a new Reader of the type that has been identified
        $reader = IOFactory::createReader($inputFileType);
        // Load $inputFileName to a Spreadsheet Object
        $data = $reader->load($filePath)->getActiveSheet()->toArray();
        $this->keys = $data[0];
        $this->data = array_slice($data, 1);
    }
}

Create an SQL reader

Next, in the src/Helper/Reader folder, create a new file named SQLReader.php with the following code.

<?php

namespace App\Helper\Reader;

final class SQLReader extends AbstractReader 
{
    public function read(string $filePath): void 
    {
        $sql = file_get_contents($filePath);
        $components = explode(PHP_EOL, $sql);
        $header = $components[0];
        $this->parseKeys($header);
        $data = array_slice($components, 1);
        $this->parseData($data);
    }
    
    private function parseKeys(string $header): void 
    {
        $formattedHeader = $this->getFormattedSQLString($header);
        $this->keys = explode(',', $formattedHeader);
    }
    
    private function getFormattedSQLString(string $header): string 
    {
        $replacementPattern = [
            '/INSERT INTO [A-Za-z`]* /',
            '/ VALUES/',
            '/[;`()\']/',
        ];
        return preg_replace($replacementPattern, '', $header);
    }
    
    private function parseData(array $data): void 
    {
        $getCorrectNullValue = fn(string $input) => $input === 'NULL' ? null : $input;
        foreach ($data as $datum) {
            $formattedDatum = $this->getFormattedDatum($datum);
            $this->data[] = array_map($getCorrectNullValue, $formattedDatum);
        }
    }
    
    private function getFormattedDatum(string $datum): array 
    {
        $formattedSQLString = $this->getFormattedSQLString($datum);
        $formattedString = preg_replace('/, /', ',', $formattedSQLString);
        $formattedDatum = explode(',', $formattedString);
        array_pop($formattedDatum);
        return $formattedDatum;
    }
}

Create the writer services

The writer services will follow the same hierarchy as the reader services. An abstract writer will be used to declare the fields and a base function, while the child writers will override the base function to provide the required functionality for the specified format.

In the writer folder, create a new file named AbstractWriter.php,and add the following code to it.

<?php

namespace App\Helper\Writer;

use App\Helper\Reader\FileContent;

abstract class AbstractWriter 
{
    protected readonly string $fileName;

    public function __construct(
        protected readonly string $saveLocation,
        string                    $fileName,
    ) {
        $this->fileName = strtolower($fileName);
    }

    public abstract function write(FileContent $content): void;
}

Each writer will be initialised by providing the location for the file to be written to and a name for the file. By overriding the write() function, they will be able to write the content in the specified format.

Create a JSON writer

In the writer folder, create a new file named JSONWriter.php and add the following code to it.

<?php

namespace App\Helper\Writer;

use App\Helper\Reader\FileContent;

final readonly class JSONWriter extends AbstractWriter 
{
    public function write(FileContent $content): void {
        $json = [];
        foreach ($content->data as $datum) {
            $result = [];
            foreach ($datum as $key => $value) {
                $result[$content->keys[$key]] = $value;
            }
            $json[] = $result;
        }
        file_put_contents(
            "$this->saveLocation/$this->fileName.json",
            json_encode(
                $json, 
                JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
            )
        );
    }
}

Create a spreadsheet writer

In the writer folder, create a new file named SpreadsheetWriter.php and add the following code to it.

<?php

namespace App\Helper\Writer;

use App\Helper\Reader\FileContent;
use PhpOffice\PhpSpreadsheet\{IOFactory, Spreadsheet, Worksheet\Worksheet};
use PhpOffice\PhpSpreadsheet\Style\{Alignment, Border, Color, Style};

final class SpreadsheetWriter extends AbstractWriter 
{
    private int $rowIndex = 1;

    private string $type;

    private Spreadsheet $spreadsheet;

    public function __construct(
        string $saveLocation,
        string $fileName,
        bool   $isCSV
    ) 
    {
        parent::__construct($saveLocation, $fileName);
        $this->type = $isCSV ? 'Csv' : 'Xlsx';
        $this->spreadsheet = new Spreadsheet;
    }

    public function write(FileContent $content): void 
    {
        $this->writeKeys($content->keys);
        $this->writeData($content->data);
        $this->autosizeColumns();
        $this->save();
    }

    private function writeKeys(array $keys): void 
    {
        foreach ($keys as $index => $value) {
            $column = $this->getColumnFromNumber($index);
            $this->writeHeader("$column$this->rowIndex", ucwords($value));
        }
        $this->rowIndex++;
    }

    private function getColumnFromNumber(int $number): string 
    {
        $letter = chr(65 + ($number % 26));
        $remainder = intval($number / 26);
        if ($remainder > 0) {
            return $this->getColumnFromNumber($remainder - 1).$letter;
        }
        return $letter;
    }

    protected function writeHeader(string $cell, string $value): void 
    {
        $this->writeToCell($cell, $value);
        $this->getStyle($cell)->getFont()->setBold(true);
        $this->applyThinBorder($cell);
        $this->getStyle($cell)
            ->getAlignment()
            ->setHorizontal(Alignment::HORIZONTAL_CENTER);
    }

    protected function writeToCell(string $cell, ?string $value): void 
    {
        $this->getActiveSheet()->setCellValue($cell, $value);
    }
    
    protected function getActiveSheet(): Worksheet 
    {
        return $this->spreadsheet->getActiveSheet();
    }
    
    private function getStyle(string $cell): Style 
    {
        return $this->getActiveSheet()->getStyle($cell);
    }
    
    protected function applyThinBorder(string $range): void 
    {
        $this->getStyle($range)->applyFromArray(
            [
                'borders'   => [
                    'allBorders' => [
                        'borderStyle' => Border::BORDER_THIN,
                        'color'       => [
                            'argb' => Color::COLOR_BLACK,
                        ],
                    ],
                ],
                'alignment' => [
                    'horizontal' => Alignment::HORIZONTAL_JUSTIFY,
                ],
            ]
        );
    }

    private function writeData(array $data): void 
    {
        foreach ($data as $datum) {
            foreach ($datum as $index => $value) {
                $column = $this->getColumnFromNumber($index);
                $cell = "$column$this->rowIndex";
                $this->writeToCell($cell, $value);
                $this->applyThinBorder($cell);
            }
            $this->rowIndex++;
        }
    }

    private function autosizeColumns(): void 
    {
        foreach ($this->spreadsheet->getWorksheetIterator() as $worksheet) {
            $this->spreadsheet->setActiveSheetIndex($this->spreadsheet->getIndex($worksheet));
            $sheet = $this->spreadsheet->getActiveSheet();
            $cellIterator = $sheet->getRowIterator()->current()->getCellIterator();
            $cellIterator->setIterateOnlyExistingCells(true);
            foreach ($cellIterator as $cell) {
                $sheet->getColumnDimension($cell->getColumn())->setAutoSize(true);
            }
        }
    }

    private function save(): void 
    {
        $writer = IOFactory::createWriter($this->spreadsheet, $this->type);
        $fileExtension = strtolower($this->type);
        $writer->save("$this->saveLocation/$this->fileName.$fileExtension");
    }
}

Because the SpreadsheetWriter can be used to write either CSV or XLSX files, the constructor function is overridden to allow you specify whether or not the writer will be writing a CSV file or not.

Create an SQL writer

In the writer folder, create a new file named SQLWriter.php and add the following code to it.

<?php

namespace App\Helper\Writer;

use App\Helper\Reader\FileContent;

final class SQLWriter extends AbstractWriter
{
    private string $sql = '';

    public function write(FileContent $content): void
    {
        $this->writeInsertStatement($content->keys);
        $this->writeValues($content->data);
        file_put_contents("$this->saveLocation/$this->fileName.sql", $this->sql);
    }
    
    private function writeInsertStatement(array $keys): void
    {
        $keyString = join("`,`", $keys);
        $this->sql .= "INSERT INTO `$this->fileName` (`$keyString`) VALUES \n";
    }
    
    private function writeValues(array $data): void
    {
        $callback = function (?string $item): string {
            if (is_null($item)) {
                return 'NULL';
            }
            return is_numeric($item) ? $item : "'$item'";
        };
        foreach ($data as $datum) {
            $datumString = join(', ', array_map($callback, $datum));
            $this->sql .= "\n($datumString),";
        }
        $this->sql .= ';';
        $this->sql = str_replace(',;', ';', $this->sql);
    }
}

Create the converter services

The converter services are the glue between the reader and writer services.

The strategy for implementation will differ slightly from that which was used in creating the readers and writers. Instead of an abstract class being used, an interface will be defined — specifying the methods which all the converters must implement.

This is because when you are creating the service that determines which converter to use, your application will be more flexible if it interacts with an interface rather than a concrete implementation.

In the Converter folder, create a new file named FileConverterInterface.php and add the following code to it.

<?php

namespace App\Helper\Converter;

use App\Helper\Reader\AbstractReader;
use App\Helper\Writer\AbstractWriter;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.converter')]
interface FileConverterInterface
{
    public function supports(string $fileExtension): bool;
    public function getReader(): AbstractReader;
    public function getWriter(string $saveLocation, string $fileName): AbstractWriter;
    public function getSupportedFormat(): string;
}

To implement this interface, four functions must be declared:

  1. supports(string $fileExtension): Given a file extension, a converter must return a boolean indicating whether or not it can handle files bearing that extension.
  2. getReader(): A converter must return a reader which can be used to generate the FileContent.
  3. getWriter(string $saveLocation, string $fileName): A converter must return a writer which will write the file in the expected format, to the provided location, and save it with the specified name.
  4. getSupportedFormat(): A converter must return a string corresponding to the format it supports for conversion.

Pay attention to the AutoconfigureTag attribute declared on the FileConverterInterface. This applies the ‘app.converter’ tag to all services that implement the interface.

Create a CSV converter

In the Converter folder, create a new file named CSVConverter.php and add the following code to it.

<?php

namespace App\Helper\Converter;

use App\Helper\Reader\{AbstractReader, SpreadsheetReader};
use App\Helper\Writer\{AbstractWriter, SpreadsheetWriter};

class CSVConverter implements FileConverterInterface
{
    public function supports(string $fileExtension): bool
    {
        return $fileExtension === 'csv';
    }

    public function getReader(): AbstractReader
    {
        return new SpreadsheetReader;
    }

    public function getWriter(string $saveLocation, string $fileName): AbstractWriter
    {
        return new SpreadsheetWriter($saveLocation, $fileName, isCSV: true);
    }

    public function getSupportedFormat(): string
    {
        return 'csv';
    }
}

Create a JSON converter

In the Converter folder, create a new file named JsonConverter.php, and add the following code to it.

<?php

namespace App\Helper\Converter;

use App\Helper\Reader\{AbstractReader, JsonReader};
use App\Helper\Writer\{AbstractWriter, JSONWriter};

class JsonConverter implements FileConverterInterface
{
    public function supports(string $fileExtension): bool
    {
        return $fileExtension === 'json';
    }

    public function getReader(): AbstractReader
    {
        return new JsonReader;
    }
    
    public function getWriter(string $saveLocation, string $fileName): AbstractWriter
    {
        return new JSONWriter($saveLocation, $fileName);
    }
    
    public function getSupportedFormat(): string
    {
        return 'json';
    }
}

Create an SQL converter

In the Converter folder, create a new file named SQLConverter.php and add the following code to it.

<?php

namespace App\Helper\Converter;

use App\Helper\Reader\{AbstractReader, SQLReader};
use App\Helper\Writer\{AbstractWriter, SQLWriter};

class SQLConverter implements FileConverterInterface
{
    public function supports(string $fileExtension): bool
    {
        return $fileExtension === 'sql';
    }

    public function getReader(): AbstractReader
    {
        return new SQLReader;
    }
    
    public function getWriter(string $saveLocation, string $fileName): AbstractWriter
    {
        return new SQLWriter($saveLocation, $fileName);
    }
    
    public function getSupportedFormat(): string
    {
        return 'sql';
    }
}

Create an XLSX converter

In the Converter folder, create a new file named XLSXConverter.php and add the following code to it.

<?php

namespace App\Helper\Converter;

use App\Helper\Reader\{AbstractReader, SpreadsheetReader};
use App\Helper\Writer\{AbstractWriter, SpreadsheetWriter};

class XLSXConverter implements FileConverterInterface
{
    public function supports(string $fileExtension): bool
    {
        return $fileExtension === 'xlsx';
    }

    public function getReader(): AbstractReader
    {
        return new SpreadsheetReader;
    }
    
    public function getWriter(string $saveLocation, string $fileName): AbstractWriter
    {
        return new SpreadsheetWriter($saveLocation, $fileName, isCSV: false);
    }
    
    public function getSupportedFormat(): string
    {
        return 'xlsx';
    }
}

Before proceeding, create a new exception which will be thrown if the conversion service cannot find an appropriate converter. In the Converter folder, create a new file named UnsupportedFormatException.php, and add the following code to it.

<?php

namespace App\Helper\Converter;

use Exception;

class UnsupportedFormatException extends Exception
{
    public function __construct($format)
    {
        parent::__construct("This application does not support the conversion of $format files");
    }
}

Create the file conversion service

In the src folder, create a new folder named Service, and in that new folder create a new file named ConversionService.php. Then, add the following code to the newly created file.

<?php

namespace App\Service;

use App\Entity\Conversion;
use App\Helper\Converter\FileConverterInterface;
use App\Helper\Converter\UnsupportedFormatException;
use App\Helper\Reader\FileReadException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
use Symfony\Component\HttpFoundation\File\UploadedFile;

class ConversionService
{
    /**@var FileConverterInterface[] $converters */
    private array $converters;

    private array $availableFormats;

    public function __construct(
        #[AutowireIterator('app.converter')]
        iterable                       $converters,
        #[Autowire('%env(resolve:OUTPUT_FOLDER)%')]
        private readonly string        $saveLocation,
        private EntityManagerInterface $em
    ) {
        $this->converters = iterator_to_array($converters);
        $this->setAvailableFormats();
    }

    private function setAvailableFormats(): void
    {
        $callback = function (array $carry, FileConverterInterface $converter): array {
            $supportedFormat = $converter->getSupportedFormat();
            return $carry + [strtoupper($supportedFormat) => $supportedFormat];
        };
        $this->availableFormats = array_reduce($this->converters, $callback, []);
    }

    public function getAvailableFormats(): array
    {
        return $this->availableFormats;
    }

    /**
     * @throws UnsupportedFormatException| FileReadException
     */
    public function convert(Conversion $conversion, UploadedFile $uploadedFile): void
    {
        $conversion->setUploadedFileSize($uploadedFile->getSize());
        $conversion->setUploadedFileName($uploadedFile->getClientOriginalName());
        $inputFormat = $uploadedFile->getClientOriginalExtension();
        $conversion->setUploadedFileFormat($inputFormat);
        $outputFormat = $conversion->getConvertedFileFormat();
        if ($inputFormat === $outputFormat) {
            throw new FileReadException('Both formats cannot be the same');
        }
        $fileName = uniqid("FCA_");
        $reader = $this->getAppropriateConverter($inputFormat)->getReader();
        $reader->read($uploadedFile->getRealPath());
        $parsedContent = $reader->getContent();
        $writer = $this->getAppropriateConverter($outputFormat)->getWriter($this->saveLocation, $fileName);
        $writer->write($parsedContent);
        $conversion->setConvertedFileName($fileName);
        $conversion->setConvertedFileSize(filesize("$this->saveLocation/{$conversion->getConvertedFileName()}"));
        $this->em->persist($conversion);
        $this->em->flush();
    }

    /**
     * @throws UnsupportedFormatException
     */
    private function getAppropriateConverter(string $format): FileConverterInterface
    {
        foreach ($this->converters as $converter) {
            if ($converter->supports($format)) {
                return $converter;
            }
        }
        throw new UnsupportedFormatException($format);
    }
}

This service has three fields:

  1. converters: This is an array of objects which implement the FileConverterInterface you created earlier
  2. availableFormats: This is an array containing the file formats your application currently supports
  3. saveLocation: This is the location all converted files should be written to. Using the Autowire attribute, you are able to inject the location to the output folder you created earlier

In addition to receiving the location of the output folder as a constructor parameter, this service also retrieves an iterable named converters which it converts to an array and saves in the converters array. Using the AutowireIterator attribute, all the services that implement the FileConverterInterface are injected as an array into the service.

Create a form for uploading files

Create a new form object using the following command:

symfony console make:form Conversion

Respond to the resulting prompt as shown below.

The name of Entity or fully qualified model class name that the new form will be bound to (empty for none):
 > Conversion

Open the newly created src/Form/ConversionType.php and update it to match the following code.

<?php

namespace App\Form;

use App\Entity\Conversion;
use App\Service\ConversionService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\File;

class ConversionType extends AbstractType
{
    private array $availableFormats;

    public function __construct(ConversionService $conversionService)
    {
        $this->availableFormats = $conversionService->getAvailableFormats();
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add(
                'convertedFileFormat',
                ChoiceType::class,
                ['choices' => $this->availableFormats, 'label' => 'Convert to']
            )
            ->add('nameToSaveAs', TextType::class, ['label' => 'Save file with name'])
            ->add('file', FileType::class, [
                'label'       => 'Input file',
                'mapped'      => false,
                'required'    => true,
                'constraints' => [
                    new File(['maxSize' => '10M']),
                ],
            ])->add('save', SubmitType::class, ['label' => 'Submit']);
    }
    
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Conversion::class,]);
    }
}

Based on the implementation of the buildForm() function, the form will have the following fields:

  • A drop down named convertedFileFormat containing all the currently supported data formats
  • A text field named nameToSaveAs which takes a name with which to save the file
  • A file input field which allows the user to select a file to convert. This field is not mapped to the Conversion entity, and has a maximum file limit of ten megabytes.
  • A save button which submits the provided information for further processing

Create a controller

Next, create a new controller to handle incoming requests using the following command.

symfony console make:controller Conversion

Open the newly created file in src/Controller/ConversionController.php and update its content to match the following.

<?php

namespace App\Controller;

use App\Entity\Conversion;
use App\Form\ConversionType;
use App\Helper\Converter\UnsupportedFormatException;
use App\Helper\Reader\FileReadException;
use App\Repository\ConversionRepository;
use App\Service\ConversionService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/conversion/', name: 'app_conversion_')]
class ConversionController extends AbstractController
{
    public function __construct(
        #[Autowire('%env(resolve:OUTPUT_FOLDER)%')]
        private readonly string $saveLocation
    ) {}

    #[Route('', name: 'index')]
    public function index(ConversionService $converter, Request $request): Response
    {
        $input = new Conversion;
        $error = null;
        $form = $this->createForm(ConversionType::class, $input);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            try {
                $uploadedFile = $form->get('file')->getData();
                assert($uploadedFile instanceof UploadedFile);
                $converter->convert($input, $uploadedFile);
                return $this->redirectToRoute('app_conversion_result', ['id' => $input->getId()]);
            } catch (UnsupportedFormatException | FileReadException $e) {
                $error = $e->getMessage();
            }
        }
        return $this->render('conversion/index.html.twig', [
            'controller_name' => 'ConverterController',
            'form'            => $form->createView(),
            'error'           => $error,
        ]);
    }

    #[Route('result/{id}', name: 'result', methods: ['GET'])]
    public function getConversion(Conversion $conversion): BinaryFileResponse
    {
        return $this->file(
            "$this->saveLocation/{$conversion->getConvertedFileName()}",
            "{$conversion->getNameToSaveAs()}.{$conversion->getConvertedFileFormat()}"
        );
    }

    #[Route('history', name: 'history', methods: ['GET'])]
    public function geRecentConversions(ConversionRepository $repository): Response
    {
        return $this->render('conversion/history.html.twig', [
            'history' => $repository->getConversions(),
        ]);
    }
}

The application will have four endpoints as shown below.

By default, you want the index page to return the conversion form. At the moment, it returns the default Symfony welcome page. To fix that, add the following route in config/routes.yaml.

index:
   path: /
   controller: App\Controller\ConversionController::index

With that, the backend of the application is in place. The next thing is to create (or update) the templates which will be used in rendering the application’s views.

Create the view templates

The controller you created earlier renders and returns some views based on templates you have not yet created or updated. So, start by updating the code in templates/base.html.twig to match the following.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{% block title %}Welcome!{% endblock %}</title>
    <link rel="icon"
          href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
    <link rel="stylesheet" href="https://rsms.me/inter/inter.css">
    {% block stylesheets %}
        {{ encore_entry_link_tags('app') }}
    {% endblock %}
    {% block javascripts %}
        {{ encore_entry_script_tags('app') }}
    {% endblock %}
</head>
<body>
    {% set activeMenuItem = 'inline-block border border-blue-500 rounded py-1 px-3 bg-blue-500 text-white' %}
    {% set defaultMenuItem = 'inline-block border border-white rounded hover:border-gray-200 text-blue-500 hover:bg-gray-200 py-1 px-3' %}
    <ul class="flex flex-row justify-center p-6">
        <li class="mr-6">
            <a class="{{ app.current_route is same as 'app_conversion_index' ? activeMenuItem : defaultMenuItem }}" href="{{ url('app_conversion_index') }}">Home</a>
        </li>
        <li class="mr-6">
            <a class="{{ app.current_route is same as 'app_conversion_history' ? activeMenuItem : defaultMenuItem }}" href="{{ url('app_conversion_history') }}">Conversion history</a>
        </li>
    </ul>
{% block body %}{% endblock %}
</body>
</html>

In the base template, you create an entrypoint for your encore assets, and import the Inter font which will be used in the application. In addition to that, you created a navigation menu which allows you to switch between the index page and conversion history.

Next, create a template to render the conversion form. Update the code in templates/conversion/index.html.twig to match the following.

{% extends 'base.html.twig' %}
{% block title %}Convert File{% endblock %}
{% block body %}
    <div class="flex flex-col min-h-screen justify-center items-center">
        {% if error is not null %}
            <div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400"
                 role="alert">
                <span class="font-medium">Error!</span> {{ error }}
            </div>
        {% endif %}
        <div class="w-full max-w-xs">
            <div class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
                {{ form_start(form) }}
                {{ form_row(form.file) }}
                {{ form_row(form.nameToSaveAs) }}
                {{ form_row(form.convertedFileFormat) }}
                {{ form_row(form.save) }}
                {{ form_end(form) }}
            </div>
        </div>
    </div>
{% endblock %}

This template renders each row of the form and wraps it in a div to position it in the middle of the screen. You also made provision for error messages in the event that invalid data was submitted.

Finally, create a new file named history.html.twig in the templates/conversion folder and add the following code to it.

{% extends 'base.html.twig' %}
{% block title %}Conversion History{% endblock %}
{% block body %}
    <div class="flex flex-row min-h-screen justify-center items-center">
        {% if history is not empty %}
            <div class="relative overflow-x-auto shadow-md sm:rounded-lg">
                <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
                    <thead class="text-xs text-center text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
                    <tr>
                        <th scope="col" class="px-6 py-3" colspan="3">Uploaded File Details</th>
                        <th scope="col" class="px-6 py-3" colspan="4">Converted File Details</th>
                        <th></th>
                    </tr>
                    <tr>
                        <th scope="col" class="px-6 py-3">Name</th>
                        <th scope="col" class="px-6 py-3">Size</th>
                        <th scope="col" class="px-6 py-3">Format</th>
                        <th scope="col" class="px-6 py-3">Name</th>
                        <th scope="col" class="px-6 py-3">Size</th>
                        <th scope="col" class="px-6 py-3">Format</th>
                        <th scope="col" class="px-6 py-3">Conversion Date</th>
                        <th scope="col" class="px-6 py-3"></th>
                    </tr>
                    </thead>
                    <tbody>
                    {% for conversion in history %}
                        <tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
                            <th scope="row"
                                class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
                                {{ conversion.uploadedFileName }}
                            </th>
                            <td class="px-6 py-4">
                                {{ conversion.uploadedFileSize }}
                            </td>
                            <td class="px-6 py-4">
                                {{ conversion.uploadedFileFormat }}
                            </td>
                            <td class="px-6 py-4">
                                {{ conversion.nameToSaveAs }}
                            </td>
                            <td class="px-6 py-4">
                                {{ conversion.convertedFileSize }}
                            </td>
                            <td class="px-6 py-4">
                                {{ conversion.convertedFileFormat }}
                            </td>
                            <td class="px-6 py-4">
                                {{ conversion.conversionDate | date('l F jS, Y') }}
                            </td>
                            <td class="px-6 py-4 text-right">
                                <a href="{{ url(app_conversion_result, { id:conversion.id }) }}"
                                   class="font-medium text-blue-600 dark:text-blue-500 hover:underline">Download</a>
                            </td>
                        </tr>
                    {% endfor %}
                    </tbody>
                </table>
            </div>
        {% else %}
            <div class="max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700">
                <h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">No conversions yet</h5>
                <p class="mb-3 font-normal text-gray-700 dark:text-gray-400">You haven't uploaded any files for
                    conversion yet.</p>
                <a href="{{ url('app_conversion_index') }}"
                   class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
                    Get started
                    <svg class="rtl:rotate-180 w-3.5 h-3.5 ms-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
                         fill="none" viewBox="0 0 14 10">
                        <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                              d="M1 5h12m0 0L9 1m4 4L9 9"/>
                    </svg>
                </a>
            </div>
        {% endif %}
    </div>
{% endblock %}

With the templates in place, you can install Tailwind CSS and compile your webpack assets.

Install Tailwind and Flowbite

Tailwind and Flowbite will be used to beautify the display of your application. Since you have already installed the webpack bundle, install Tailwind and Flowbite using the following commands

npm install -D tailwindcss postcss postcss-loader autoprefixer
npx tailwindcss init -p
npm install flowbite

Next, at the root of the project folder, update your webpack.config.js file to match the following code.

const Encore = require('@symfony/webpack-encore');
if (!Encore.isRuntimeEnvironmentConfigured()) {
    Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}
Encore
    .setOutputPath('public/build/')
    .setPublicPath('/build')
    .addEntry('app', './assets/app.js')
    .splitEntryChunks()
    .enableSingleRuntimeChunk()
    .cleanupOutputBeforeBuild()
    .enableBuildNotifications()
    .enableSourceMaps(!Encore.isProduction())
    .enableVersioning(Encore.isProduction())
    .configureBabelPresetEnv((config) => {
        config.useBuiltIns = 'usage';
        config.corejs = '3.23';
    })
    .enablePostCssLoader()
;
module.exports = Encore.getWebpackConfig();

Now, update tailwind.config.js to match the following code.

const defaultTheme = require('tailwindcss/defaultTheme');
/** @type {import('tailwindcss').Config} */
module.exports = {
    content: [
        "./vendor/tales-from-a-dev/flowbite-bundle/templates/**/*.html.twig",
        "./assets/**/*.js",
        "./templates/**/*.html.twig",
        "./node_modules/flowbite/**/*.js"
    ],
    theme: {
        extend: {
            fontFamily: {
                sans: ['Inter var', ...defaultTheme.fontFamily.sans],
            },
        },
    },
    plugins: [
        require('flowbite/plugin')
    ],
};

Next, import the Tailwind directives, by updating the code in assets/styles/app.css to match the following code.

@tailwind base;
@tailwind components;
@tailwind utilities;

Also, make sure that the app.css file is imported in assets/app.js.

import './styles/app.css';

Next, add the Flowbite form theme to Twig. To do this, open config/packages/twig.yaml and update the code to match the following.

twig:
    file_name_pattern: '*.twig'
    form_themes:
        - '@TalesFromADevFlowbite/form/default.html.twig'
when@test:
    twig:
        strict_variables: true

Finally, compile your webpack assets using the following command.

npm run dev

Run the application using the following command.

symfony serve

By default, the application runs on port 8000. Navigate to that port in your browser and your application will run as shown below.

Interface for converting files to CSV format displayed on Safari browser.

Conclusion

There you have it! Not only have you built an application capable of handling conversions for your everyday activities, you’ve structured it in a way that allows for easy addition of new formats in future. You can review the final codebase for this article on GitHub, should you get stuck at any point. I’m excited to see what else you come up with.

The journey doesn’t stop here however. In a subsequent tutorial, I will show you how Symfony can make the process of adding new converters even easier. Until next time, make peace not war✌🏾

Joseph Udonsak is a software engineer with a passion for solving challenges — be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends. Find him at LinkedIn, Medium, and Dev.to.