Generate Custom Code with Symfony

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

Generate Custom Code With Symfony

Very few frameworks come close to Symfony in terms of developer experience. One major contributor to this is the Symfony MakerBundle which helps developers auto-generate code associated with controllers, forms, and entities. However, it doesn’t stop there as the MakerBundle allows you to create your custom Makers — auto-generating code for your particular use case.

In the previous part of this series, you created an application that converts between files of different formats. In this tutorial, you will create a custom Maker which auto-generates the code for creating a new converter. You will conclude by creating a new converter for a popular format in use today — XML!

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)

Set up a new Symfony project

If you already have the code from the first part in this series, you can skip this section. However, if you're just joining, you can clone the repository to get started.

git clone https://github.com/ybjozee/symfony_file_converter.git
cd symfony_file_converter
git checkout base

Next, install the project's dependencies using Composer, by running the command below.

composer install

Provision the database

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

Then, update your database schema using the following commands:

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

Set up Tailwind CSS

Tailwind CSS and Flowbite are used to style the application. The JavaScript assets associated with this project are managed using Encore Webpack. Install the JavaScript dependencies and build your assets using the following commands.

npm install
npm run dev

After that, start the application by running 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. By default you should see the following conversion formats in the drop down.

This is a good starting point. Before adding a new conversion format, we'll create a Maker command to help with creating new converters.

Build the Maker command

In the src/Helper folder, create a new file named Maker. This folder will contain the code templates, the help text for the command, and the code for creating new files.

Add resources

In the Maker folder, create a new folder named Resources, and in it create two new folders named help and skeleton.

In the help folder, create a new file named make_converter.txt and add the following to it.

The <info>%command.name%</info> command creates a new reader, writer, and converter for a data format.
<info>php %command.full_name% XML</info>
If the argument is missing, the command will ask for the data format interactively.

This text file will be rendered when the user passes the --help flag to your command.

Add code templates

In the Resources/skeleton folder, create a new file named Reader.tpl.php and add the following code to it.

<?= "<?php\n" ?>
namespace <?= $namespace; ?>;
final class <?= $class_name; ?> extends AbstractReader {
    /**
    * @throws FileReadException
    */
    public function read(string $filePath)
    : void {
        //TODO: Write implementation for generating $this->keys and $this->data arrays
    }
}

This is the template for the reader service which every converter must have. This service extends the AbstractReader class located in src/Helper/Reader/AbstractReader.php, implementing the abstract function read().

Next, in the Resources/skeleton folder, create a new file named Writer.tpl.php and add the following code to it.

<?= "<?php\n" ?>
namespace <?= $namespace ?>;
use App\Helper\Reader\FileContent;
final class <?= $class_name ?> extends AbstractWriter {
    public function write(FileContent $content): void {
        //TODO: Write implementation for writing data in the appropriate format
    }
}

In a similar manner to the Reader template, this is the template for the writer service which every converter must have. This service extends the AbstractWriter class located in src/Helper/Reader/AbstractWriter.php, implementing the abstract function write().

Then, in the Resources/skeleton folder, create a new file named Converter.tpl.php and add the following code to it.

<?php
?>
<?= "<?php\n" ?>
namespace <?= $namespace ?>;
use App\Helper\Reader\{AbstractReader, <?= $readerClassName ?>};
use App\Helper\Writer\{AbstractWriter, <?= $writerClassName ?>};
class <?= $class_name; ?> implements FileConverterInterface {
    public function supports(string $fileExtension) : bool {
        //TODO: Condition for which converter can be used
    }
    public function getReader(): AbstractReader {
        //TODO: Return instance of reader class
    }
    public function getWriter(string $saveLocation, string $fileName): AbstractWriter {
        //TODO: Return instance of writer class
    }
    public function getSupportedFormat(): string {
        //TODO: Return string representation of supported format
    }
}

Every converter must implement the FileConverterInterface located in src/Helper/Converter/FileConverterInterface.php. This template generates a new converter implementing the interface. It also imports the associated reader and writer services for you, saving you the hassle of remembering to do so.

With these in place, you can create a new maker that uses these resources for code generation.

Create the Maker

In the Helper/Maker folder, create a new file named MakeConverter.php, and add the following code to it.

<?php

namespace App\Helper\Maker;

use Symfony\Bundle\MakerBundle\ConsoleStyle;
use Symfony\Bundle\MakerBundle\DependencyBuilder;
use Symfony\Bundle\MakerBundle\Generator;
use Symfony\Bundle\MakerBundle\InputConfiguration;
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;

/**
 * @method string getCommandDescription()
 */
class MakeConverter extends AbstractMaker 
{
    /**
     * @inheritDoc
     */
    public static function getCommandName(): string 
    {
        return 'make:converter';
    }
    
    /**
     * @inheritDoc
     */
    public function configureCommand(Command $command, InputConfiguration $inputConfig): void 
    {
        $command->addArgument(
            'data-format',
            InputArgument::OPTIONAL,
            'The type of converter you want to create (e.g. <fg=yellow>XML</>)',
        )->setHelp(file_get_contents(__DIR__.'/Resources/help/make_converter.txt'));
    }

    public static function getCommandDescription(): string 
    {
        return 'Create the required classes for a new converter';
    }
    
    /**
     * @inheritDoc
     */
    public function configureDependencies(DependencyBuilder $dependencies): void 
    {
    }
    
    /**
     * @inheritDoc
     */
    public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void 
    {
        $dataFormat = $input->getArgument('data-format');
        $readerClassNameDetails = $generator->createClassNameDetails("Reader\\$dataFormat", 'Helper', 'Reader');
        $generator->generateClass(
            $readerClassNameDetails->getFullName(),
            __DIR__.'/Resources/skeleton/Reader.tpl.php',
        );
        $writerClassNameDetails = $generator->createClassNameDetails("Writer\\$dataFormat", 'Helper', 'Writer');
        $generator->generateClass(
            $writerClassNameDetails->getFullName(),
            __DIR__.'/Resources/skeleton/Writer.tpl.php'
        );
        $converterClassNameDetails =
            $generator->createClassNameDetails("Converter\\$dataFormat", 'Helper', 'Converter');
        $generator->generateClass(
            $converterClassNameDetails->getFullName(),
            __DIR__.'/Resources/skeleton/Converter.tpl.php',
            [
                'readerClassName' => $readerClassNameDetails->getShortName(),
                'writerClassName' => $writerClassNameDetails->getShortName(),
            ]
        );
        $generator->writeChanges();

        $io->newLine();
        $io->writeln('<fg=green>Your reader, writer, and converter have been created successfully</>.');
        $io->writeln(
            'Please <fg=yellow>review</>, <fg=yellow>edit</> and <fg=yellow>commit</> them: these files are <fg=yellow>yours</>.'
        );
        $io->newLine();
    }
}

The secret to creating your own Maker is extending the AbstractMaker class and implementing the required functions.

The getCommandName() function should return a string corresponding to the name of the command. In your case you return ‘make:converter’ so that you can create a new converter by running symfony console make:converter.

In the configureCommand() function, you specify the arguments for the command and also set the help text. The command has only one argument which is the data format you want to build a converter for. Observe that the help message is set to the content of the src/Helper/Maker/Resources/help/make_converter.txt file you created earlier.

The getCommandDescription() function returns a brief description of the command and what it does.

If your command depends on any other bundles, you can configure them in the configureDependencies() function. This does not apply to your use case so the function is left empty.

The last function you implement is generate(). This function performs the following actions:

  1. Retrieves the data format from the data-format command argument. If this argument is not provided, the command will ask for it.
  2. Creates a new reader class using the data format and the Reader template you declared earlier
  3. Creates a new writer class using the data format and the Writer template you created earlier
  4. Creates a new converter class using the data format and the Converter template you created earlier. This template requires some extra parameters for the reader and writer class names. They are passed as an array to the generateClass() function.
  5. Writes the changes to actually create the files
  6. Writes a success message to the user

Test your implementation by running the following command.

symfony console make:converter --help
Output when the help flag is passed to the make:converter command

Create a new converter

With your Maker command in place, create a new converter using the following command.

symfony console make:converter XML

You should see the following output printed to the terminal:

Output after running the command to create new converter

To successfully handle a data format for conversion, you require three services:

  1. A reader service to read data in the XML format. The src/Helper/Reader/XMLReader.php file code contains the code for this service.
  2. A writer service that takes formatted data and writes it out in XML format. The src/Helper/Writer/XMLWriter.php file code contains the code for this service.
  3. A service that binds the reader and writer services to provide a conversion service. The src/Helper/Converter/XMLConverter.php file code contains the code for this service.

The output of the terminal command shows that the skeleton of these services have been created and are ready for you to update.

Add functionality for the XML reader

Open the newly created src/Helper/Reader/XMLReader.php fileand update its content as follows.

<?php

namespace App\Helper\Reader;

final class XMLReader extends AbstractReader
{
    /**
     * @throws FileReadException
     */
    public function read(string $filePath): void
    {
        $xml = simplexml_load_file($filePath);
        $content = json_decode(json_encode($xml), true);
        $this->validateContent($content);
        $values = array_values($content)[0];
        $this->writeKeys($values[0]);
        $this->writeData($values);
    }

    /**
     * @throws FileReadException
     */
    private function validateContent(mixed $content): void
    {
        if ($content === null || empty($content)) {
            throw new FileReadException('Invalid XML provided');
        }
    }

    private function writeKeys(array $sample): void
    {
        $attributes = array_keys($sample['@attributes']);
        unset($sample['@attributes']);
        $this->keys = array_values([...$attributes, ...array_keys($sample)]);
    }

    private function writeData(array $data): void
    {
        $formatDataCallback = function (array $datum): array {
            $attributes = array_values($datum['@attributes']);
            unset($datum['@attributes']);
            return array_values([...$attributes, ...$datum]);
        };
        $this->data = array_map($formatDataCallback, $data);
    }
}

Add functionality for the XML Writer

Open src/Helper/Writer/XMLWriter.php and update its code to match the following.

<?php

namespace App\Helper\Writer;

use App\Helper\Reader\FileContent;
use DOMDocument;
use SimpleXMLElement;
use Symfony\Component\String\Inflector\EnglishInflector;

final class XMLWriter extends AbstractWriter
{
    public function write(FileContent $content): void
    {
        $contentToWrite = $this->getContentAsArray($content);
        $xml = new SimpleXMLElement("<items/>");
        foreach ($contentToWrite as $item) {
            $child = $xml->addChild("item");
            foreach ($item as $key => $value) {
                $child->addChild($key, $value);
            }
        }
        $dom = new DOMDocument('1.0');
        $dom->preserveWhiteSpace = false;
        $dom->formatOutput = true;
        $dom->loadXML($xml->asXML());
        file_put_contents("$this->saveLocation/$this->fileName.xml", $dom->saveXML());
    }

    private function getContentAsArray(FileContent $content): array
    {
        $output = [];
        foreach ($content->data as $datum) {
            $result = [];
            foreach ($datum as $key => $value) {
                $result[$content->keys[$key]] = $value;
            }
            $output[] = $result;
        }
        return $output;
    }
}

Add functionality for the XML Converter

Edit the code in the newly created src/Helper/Converter/XMLConverter.php to match the following.

<?php

namespace App\Helper\Converter;

use App\Helper\Reader\AbstractReader;
use App\Helper\Reader\XMLReader;
use App\Helper\Writer\AbstractWriter;
use App\Helper\Writer\XMLWriter;

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

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

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

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

With these in place, your application is now able to handle files in the XML format.

Test that the application works as expected

Run the application using the following command (if you stopped it previously)

symfony serve

Loading the index page should show a new option for XML conversion as shown below.

Webpage interface for file conversion with input fields for file name, file type selection, and a submit button.

That's how to generate custom code with Symfony

There you have it! Not only did you create a new conversion format, you’ve made it easier to add new formats in the future. By using a maker command to create new converters, you don’t have to remember which files to create, what interfaces they should implement (or classes to extend), where they should be located, or what classes to import. All these are taken care of so, that you can focus on what matters - reading and writing in a new format.

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. 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.