Creating an HTML form for a class - part 2
Tags: php, html, reflection
Published: 2019-06-01 12:38:34

This post improves the concept described in the [previous post](https://reflection.to/posts/php/2-Creating_an_HTML_form_for_a_class), 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](https://docs.phpdoc.org/guides/docblocks.html) 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:
```php
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.
```php
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.
```php
/**
 * 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 `MakeInput`and `MakeForm` functions to accept an object instance as a parameter, and the `MakeObjectFromArray` function to accept a `ReflectionClass` parameter.
```php
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`
```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.