Emanuel 7 minutes read0 hearts

Creating an HTML form for a class - part 2

This post improves the concept described in the previous post, and will improve upon the implementation.

Most of the code in this post is built upon the code in the previous post, but it is self-standing, so you do not have to read the previous post, though I strongly advise you to do so.

Using reflection, not only can we inspect a class's properties or methods, but we can also inspect the DocComment of any particular property, method or class. Using the DocComment, we can annotate the properties of our class to aid our form building method. Let us extend the Article class to as follows:

class Article
{
    /**
     * @var int
     * @Readonly
     */
    public $id = 1;

    /** @var string */
    public $title;

    /**
     * @var string
     * @Textarea
     */
    public $body;

    /** @var DateTime */
    public $date;

    public function __construct()
    {
        $this->date = $this->date ?
            new DateTime($this->date) :
            new DateTime();
    }
}

We added an id property to our article, that will be readonly, and the body property should be displayed as a textarea, as specified by their respective annotations. Notice how our properties no longer have initializers, because we will also use the @var annotation to deduce their types. After parsing the DocComments into an array of annotations we can use it to build our form.

function ParseDocCommentAnnotations(string $comment): array
{
    $matches = null;
    $annotations = [];
    $lines = explode("\n", $comment);
    foreach ($lines as $line) {
        $line = trim($line);
        // match all lines that start with an @ and capture the relevant parts
        if (1 === preg_match('/^\/?\*+\s*(@.*?)(?:\s*\*\/)?$/', $line,
                $matches)) {
            $annotation = $matches[1];
            // eventually split them on white-space, to handle @var annotations
            // or others that might also specify a value, such as @min, @max etc.
            list($key, $value) = preg_split('/\s+/', $annotation, 2);
            // if the value is falsy, use the key as the value
            // we could use literal true instead or whatever makes sense
            $value = $value ?? $key;
            $annotations[strtolower($key)] = $value;
        }
    }

    return $annotations;
}

For the id property, the resulting array would be equivalent to [var' => 'int', readonly' => '@readonly']. Let us modify the MakeInput function to accept a ReflectionProperty $property parameter and an $object parameter and make use of the above function to return a form input.

/**
 * This function creates a valid HTML input with an associated label.
 * @param ReflectionProperty $property
 * @param mixed $object An instance of the class that the $property parameter
 * was declared into
 * @return string
 */
function MakeInput(ReflectionProperty $property, $object): string
{
    $annotations = ParseDocCommentAnnotations($property->getDocComment());
    if (false === array_key_exists('@var', $annotations))
        // we could throw an exception instead
        // or assume that the property is a string
        return '';

    $class = $property->getDeclaringClass();
    $name = $property->getName();
    $value = $property->getValue($object);
    $input_name = "{$class->getName()}[{$name}]";
    $is_textarea = array_key_exists('@textarea', $annotations) &&
        $annotations['@var'] === 'string';
    // the for attribute targets an element with the specified id, not the name
    $label = sprintf('<label for="%s">%s</label>', $input_name, $name);

    $input_attributes = ['name' => $input_name, 'id' => $input_name];
    if (false === $is_textarea)
        $input_attributes['value'] = $value;
    if (array_key_exists('@readonly', $annotations))
        $input_attributes['readonly'] = 'readonly';

    switch ($type = $annotations['@var']) {
        case 'bool':
            $input_attributes['type'] = 'checkbox';
            break;
        case 'int':
        case 'double':
        case 'float':
            $input_attributes['type'] = 'number';
            // this is where the power of the annotations comes in
            // you can annotate your class's properties in any way you want
            // and then use those annotations to build the form
            if (array_key_exists('@min', $annotations))
                $input_attributes['min'] = $annotations['@min'];
            if (array_key_exists('@max', $annotations))
                $input_attributes['max'] = $annotations['@max'];
            if (array_key_exists('@step', $annotations))
                $input_attributes['step'] = $annotations['@step'];
            break;
        case 'string':
            if (false === $is_textarea)
                $input_attributes['type'] = 'text';
            break;
        default:
            // maybe the annotation specifies a class that we know how to
            // display as a form input
            $class = new ReflectionClass($type);
            if ($class->implementsInterface(DateTimeInterface::class)) {
                $input_attributes['type'] = 'date';
                break;
            }
            throw new InvalidArgumentException("The property {$name}
                    with type {$type} could not be converted into an input.");
    }

    $attributes_string = '';
    foreach ($input_attributes as $k => $v)
        $attributes_string .= sprintf(' %s="%s"', $k, addslashes($v));

    if (false === $is_textarea)
        $input = "<input {$attributes_string} />";
    else
        $input = "<textarea {$attributes_string}>{$value}</textarea>";

    return $label . $input;
}

We are also refactoring the MakeInputand MakeForm functions to accept an object instance as a parameter, and the MakeObjectFromArray function to accept a ReflectionClass parameter.

function MakeInputs($object): array
{
    $inputs = [];
    $class = new ReflectionClass($object);
    $properties = $class->getProperties();
    foreach ($properties as $property)
        // we could use yield here and change the return type to Generator
        $inputs[] = MakeInput($property, $object);

    return $inputs;
}

/**
 * @param mixed $object An object instance of a class
 * that has its properties annotated
 * @return string
 */
function MakeForm($object): string
{
    $html = '<form method="POST">';
    foreach (MakeInputs($object) as $input)
        $html .= $input;
    $html .= '<input type="submit" />';
    $html .= '</form>';

    return $html;
}

function MakeObjectFromArray(ReflectionClass $class, array $values)
{
    // we do not call the constructor yet
    $instance = $class->newInstanceWithoutConstructor();
    // first we set each property to their respective value
    foreach ($values as $name => $value) {
        $property = $class->getProperty($name);
        $property->setAccessible(true);
        $property->setValue($instance, $value);
    }
    // note that we have set primitive values to our object properties
    // we late-call the constructor, like PDO does when fetching objects
    // and it re-creates the object instances from the primitive values
    $class->getConstructor()->invoke($instance);

    return $instance;
}

At last, let us write the following in our index.php

$request_method = strtoupper($_SERVER['REQUEST_METHOD']);
if ($request_method === 'GET') {
    echo MakeForm(new Article);
} elseif ($request_method === 'POST') {
    $class = new ReflectionClass(Article::class);
    $article = MakeObjectFromArray($class, $_POST[$class->getName()]);
    // validate and save article or re-show form and display errors
    // echo MakeForm($article);
    print_r($article);
}

Opening up our browser, everything is in place, the input for the id property is readonly, and the input for the body property is a textarea, as we specified in the annotations.

Subscribe

Liked this post? Subscribe to get emails about new posts in PHP.