DEV Community

yactouat
yactouat

Posted on • Updated on

a gentle introduction to client-side data fetching and rendering

Lately, as I was introducing a few students the basics of web development while working at the Wild Code School, a question was naturally raised during the development of CRUD applications in PHP: how can I implement a form to add a cruddable item on the same page that lists those items and with 2 different routes, WITHOUT reloading the page ?

pre requisites

  • have a working installation of Docker Desktop
  • use a bash/zsh etc. terminal
  • basic knowledge of HTTP and the client/server architecture
  • basic knowledge of PHP
  • know what the MVC pattern is
  • be familiar with Twig
  • basic knowledge of frontend Javascript
  • basic knowledge of MySQL/MariaDB

the initial setup

The setup that raised this question is as follows:

  • a simple MVC application
  • within that application, GET requests to /items trigger a controller's index method that shows a list of items stored in DB
  • when a POST request is made to /items/adds, a controller adds the item to the database and redirects the user to the /items index list

You can run it with a cp ./config/db.docker.php ./config/db.php && docker compose up from the root of the project.

not to reload the page on sending a new item's data

Currently our ItemController redirects the user to the index page from the PHP backend, let's create a better user experience by not relading the page at all !

For this kind of smooth experience from the end-user perspective, Javascript is mandatory, our first concern will be to listen, on the brower, to any submit event that may happen when the user fills an item add form and clicks on the submit button.

We will therefore need to:

  • add a script tag to our index view
  • create an event listener that intercepts this event

Once that's done, we also need to get the contents of the form to be able to send the item payload to the PHP backend.
We will send that payload as a JSON, which is a string representation of a key-value pairs object.
If you dont know what a JSON is, I invite you to read this excellent resource.

To achieve this objective, I slightly modified the form Twig items form template so it can process both addings et editings of items =>

{% if item.id is not defined %}
    <form method="post" action="/items/add" id="add_item_form">
        <label for="add_item_form_title">Tile</label>
        <input type="text" value="{{ item.title }}" name="title" id="add_item_form_title">
        <button>Add an item</button>
    </form>
{% else %}
    <form method="post" action="/items/edit?id={{ item.id }}" id="editTo a_item_form">
        <input type="hidden" value="{{ item.id }}" name="id">
        <label for="edit_item_form_title">Tile</label>
        <input type="text" value="{{ item.title }}" name="title" id="edit_item_form_title">
        <button>Edit an item</button>
    </form>
{% endif %}
Enter fullscreen mode Exit fullscreen mode

Also, I added a new script in our index view =>

<script>
    // we wait that our HTML markup is fully loaded before interacting with the DOM
    document.addEventListener('DOMContentLoaded', () => {

        // we get the form element
        const addItemForm = document.getElementById('add_item_form');
        // we listen to its `submit` event
        addItemForm.addEventListener('submit', e => {
            // we prevent the page from reloading
            e.preventDefault();
            // we prepare the item payload to send to the backend as a string
            const itemPayload = JSON.stringify({
                title: document.querySelector('#add_item_form input[name="title"]').value
            });
            // we test that we do intercept the `submit` event and retrieve the user input successfully
            alert(itemPayload);
        });

    });
</script>
Enter fullscreen mode Exit fullscreen mode

send a JSON payload to the PHP backend

Currently, our Javascript does not send anything to the backend, let's solve this by adding an HTTP call to our submit form event listener:

    // we wait that our HTML markup is fully loaded before interacting with the DOM
    document.addEventListener('DOMContentLoaded', () => {

        // we get the form element
        const addItemForm = document.getElementById('add_item_form');
        // we listen to its `submit` event
        addItemForm.addEventListener('submit', e => {
            // we prevent the page from reloading
            e.preventDefault();
            // we prepare the item payload to send to the backend as a string
            const itemPayload = JSON.stringify({
                title: document.querySelector('#add_item_form input[name="title"]').value
            });
            // we send our item payload to the correct URL
            fetch('/items/add', {
                // we specify the request method
                method: 'POST',
                // we say to the server that we are sending JSON
                headers: {
                    'Content-Type': 'application/json',
                },
                // we set the body of our HTTP request to be the item payload
                body: JSON.stringify(itemPayload),
            })
                // on receiving the response from the server, we turn it into a readable json
                .then((response) => response.json())
                // ...then we do whatever we want with the response on the client side 
                .then((data) => {
                    console.log('Success:', data);
                })
                // ...OR we implement an error a behavior in case our HTTP call did not succeed or transforming the response into JSON failed
                .catch((error) => {
                    console.error('Error:', error);
                });
            });

    });
Enter fullscreen mode Exit fullscreen mode

Right now, our backend call fails because PHP did not succeed in parsing the JSON input payload using the traditional $_POST array, as you can see in the network tab of your browser =>

Image description

The thing is, the $_POST array is not automatically filled with the correct key/values when sending a JSON; you can check it out yourself in the browser's network tab after adding a var_dump($_POST); die(); in the ./src/Controller/ItemController.php add method and after having sent a new form.: the array is empty !

Image description

Let's fix this. We are going to need the php://input I/O stream wrapper for this.

As the docs say =>

php://input is a read-only stream that allows you to read raw data from the request body. php://input is not available with enctype="multipart/form-data".

This means we are going to use to get our JSON in the HTTP request body. First, let's dump what's in there with a var_dump(file_get_contents('php://input')) and by sending a new form.

Image description

Hurray ! we get the contents of our JSON file, now we can parse it with the built-in json_decode PHP function directly in our ./src/Controller/ItemController.php add method =>

    /**
     * Add a new item
     */
    public function add(): ?string
    {
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {

            // transforming request input into JSON
            // note: adding `true` as a 2nd param to the `json_decode` function allows us to get an associative array
            $itemJSON = json_decode(file_get_contents('php://input'), true);

            // TODO failure behavior case JSON was not parsed correctly

            // this time, we dont clean $_POST but our JSON's data
            // $item = array_map('trim', $_POST);
            $item = array_map('trim', $itemJSON);

            // TODO validations (length, format...)

            // if validation is ok, insert and redirection
            $itemManager = new ItemManager();
            $id = $itemManager->insert($item);

            header('Location:/items/show?id=' . $id);
            return null;
        }

        return $this->twig->render('Item/add.html.twig');
    }
Enter fullscreen mode Exit fullscreen mode

We also have to correct something in the way we send our item payload from the frontend, as we were json encoding our payload twice (just noticed that now) =>

            // we prepare the item payload to send to the backend as a string
            const itemPayload = {
                title: document.querySelector('#add_item_form input[name="title"]').value
            };
/* instead of
          const itemPayload = JSON.stringify({
                title: document.querySelector('#add_item_form input[name="title"]').value
            }); 
*/
Enter fullscreen mode Exit fullscreen mode

Now we do get an array on the backend side and we are thus able to insert a new item record in the database from our model.
We can see this by reloading the page in our browser, after having added an item.

dynamic refresh of the page with the newly added item

Maybe you havent noticed, but getting back the response to this request fails on the client side as our controller still redirects the user after having called the model to insert an item.

We need now to modify our controller so it returns a JSON as well instead of doing a redirect, that way our Javascript will be able to parse the response, the main advantage of this being that we can update the DOM with the contents of the response and show the new item without having to refresh the page !

This will be a two-step process =>

  • tell PHP to return a JSON from the controller
  • with Javascript on the frontend, get the obtained response and insert its contents in the DOM

The PHP code to return a JSON =>

    public function add(): ?string
    {
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {

            // transforming request input into JSON
            // note: adding `true` as a 2nd param to the `json_decode` function allows us to get an associative array
            $itemJSON = json_decode(file_get_contents('php://input'), true);

            // TODO failure behavior case JSON was not parsed correctly

            // this time, we dont clean $_POST but our JSON's data
            // $item = array_map('trim', $_POST);
            $item = array_map('trim', $itemJSON);

            // TODO validations (length, format...)

            // if validation is ok, insert and redirection
            $itemManager = new ItemManager();
            $id = $itemManager->insert($item);

            // header('Location:/items/show?id=' . $id);
            // return null;
            // we dont redirect and we dont return null but we return a stringified item instead
            return json_encode($itemManager->selectOneById($id));
        }

        return $this->twig->render('Item/add.html.twig');
    }
Enter fullscreen mode Exit fullscreen mode

The console in the browser does not show any errors anymore and is able to parse the response JSON ;)

Let's add a list item directly in the DOM every time we successfully make a POST request to add an item from our form and the underlying Javascript code.
Our src/View/Item/index.html.twig Twig template should now looke like so =>

{% extends 'layout.html.twig' %}

{% block content %}
    {% include "Item/_form.html.twig" %}
    <h1>Items</h1>
    {# we give our UL an id so we can select it easily with JS #}
    <ul id="items_list">
        {% for item in items %}
            <li><a href="/items/show?id={{ item.id }}">{{ item.id }}. {{ item.title }}</a></li>
        {% else %}
            <li>Nothing to display</li>
        {% endfor %}
    </ul>
{% endblock %}

{% block javascript %}
<script>
    // we wait that our HTML markup is fully loaded before interacting with the DOM
    document.addEventListener('DOMContentLoaded', () => {

        // we get the form element
        const addItemForm = document.getElementById('add_item_form');
        // we listen to its `submit` event
        addItemForm.addEventListener('submit', e => {
            // we prevent the page from reloading
            e.preventDefault();
            // we prepare the item payload to send to the backend as a string
            const itemPayload = {
                title: document.querySelector('#add_item_form input[name="title"]').value
            };
            // we send our item payload to the correct URL
            fetch('/items/add', {
                // we specify the request method
                method: 'POST',
                // we say to the server that we are sending JSON
                headers: {
                    'Content-Type': 'application/json',
                },
                // we set the body of our HTTP request to be the item payload
                body: JSON.stringify(itemPayload),
            })
                // on receiving the response from the server, we turn it into a readable json
                .then((response) => response.json())
                // ...then we do whatever we want with the response on the client side 
                .then((data) => {
                    // we create a `li` that will hold our newly retrieved item
                    const itemEl = document.createElement('li');
                    // we fill this list item with the correct link
                    itemEl.innerHTML = `<a href="/items/show?id=${data.id}">${data.id}. ${data.title}</a>`;
                    // we update the DOM
                    document.getElementById('items_list').appendChild(itemEl);
                })
                // ...OR we implement an error a behavior in case our HTTP call did not succeed or transforming the response into JSON failed
                .catch((error) => {
                    console.error('Error:', error);
                });
            });

    });
</script>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Adding an item updates the DOM without any page refresh 😎.

bonus content: allow uploads of images

For now, we have just sent text from our frontend and the JSON format is perfectly suitable for that kind of input. But what if we wanted to upload an image from the frontend ?
That would be a scenario where items have an image associated to them, for instance in the context of a social network, where it's pretty common to attach images to posts.

adding images to the Item entity

The first step would be to update our database schema (in scripts/database.sql) to reflect our new scenario =>

CREATE TABLE `item` (
  `id` int(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `title` varchar(255) NOT NULL,
  -- the new field that will reference our image
  `image_url` varchar(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO `item` (`title`, `image_url`) VALUES
-- using the excellent Lorem Picsum website to seed our db with images urls
('foo', 'https://picsum.photos/200'),
('bar', 'https://picsum.photos/200'),
('baz', 'https://picsum.photos/200');
Enter fullscreen mode Exit fullscreen mode

Now you can kill and delete the Docker application stack from Docker Dekstop, delete the associated SQL volume and run docker compose up --build --force-recreate to take those changes into effect.

Now let's display these images in our index list by updating our HTML markup =>

{% block content %}
    {% include "Item/_form.html.twig" %}
    <h1>Items</h1>
    {# we give our UL an id so we can select it easily with JS #}
    <ul id="items_list">
        {% for item in items %}
            <li>
                <a href="/items/show?id={{ item.id }}">{{ item.id }}. {{ item.title }}</a>
                {% if item.image_url is not empty %}
                    <img src="{{item.image_url}}" alt="{{item.title}} associated image">
                {% endif %}
            </li>
        {% else %}
            <li>Nothing to display</li>
        {% endfor %}
    </ul>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

And voilà ! images are displayed, we can now output images from the list of items stored in the database.

But what about the input of images ? We will need to:

  • modify how we send data from the frontend to allow images to be sent
  • be able to upload files with PHP so they are stored on disk
  • allow these images to be served via an URL
  • add the uploaded image url to the item on insert in the db

modify how we send data from the frontend to allow images to be sent

We are going to send our data as a multipart/form-data input, this encoding allows to send files in multiple parts because of their large size.

So, basically, we are going to send plain JSON when there is no file input, and classical multipart/form-data when there is one. There is a Javascript built-in object that allows us to manipulate and construct multipart/form-data payloads easily: the FormData object.

First, we need to modify our add item form to allow for images input =>

    <form method="post" action="/items/add" id="add_item_form">
        <label for="add_item_form_title">Title</label>
        <input type="text" value="{{ item.title }}" name="title" id="add_item_form_title">
        <label for="add_item_image_url">Image</label>
        <input type="file" id="add_item_image_url" name="image_url">
        <button>Add an item</button>
    </form>
Enter fullscreen mode Exit fullscreen mode

Then, let's go back to our submit event listener to make a conditional HTTP call, based on the fact that there is an image to send or not =>

    // factorizing updating the DOM with a new item in one place
    const updateDomWithNewItem = (item) => {
        // we create a `li` that will hold our newly retrieved item
        const itemEl = document.createElement('li');
        // we fill this list item with the correct link
        itemEl.innerHTML = `<a href="/items/show?id=${item.id}">${item.id}. ${item.title}</a>`;
        // we update the DOM
        document.getElementById('items_list').appendChild(itemEl);
    }

    // we wait that our HTML markup is fully loaded before interacting with the DOM
    document.addEventListener('DOMContentLoaded', () => {

        // we get the form element
        const addItemForm = document.getElementById('add_item_form');
        // we listen to its `submit` event
        addItemForm.addEventListener('submit', e => {
            // we prevent the page from reloading
            e.preventDefault();
            // getting the image title
            const itemTitle = document.querySelector('#add_item_form input[name="title"]').value;
            // getting the `image_url` input if any
            const itemImage = document.getElementById('add_item_image_url').files;
            // sending JSON in case there is no file
            if (!itemImage || itemImage.length <= 0) {
                // we prepare the item payload to send to the backend as a string
                const itemPayload = {
                    title: itemTitle
                };
                // we send our item payload to the correct URL
                fetch('/items/add', {
                    // we specify the request method
                    method: 'POST',
                    // we say to the server that we are sending JSON
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    // we set the body of our HTTP request to be the item payload
                    body: JSON.stringify(itemPayload),
                })
                    // on receiving the response from the server, we turn it into a readable json
                    .then((response) => response.json())
                    // ...then we do whatever we want with the response on the client side 
                    .then((item) => updateDomWithNewItem(item))
                    // ...OR we implement an error a behavior in case our HTTP call did not succeed or transforming the response into JSON failed
                    .catch((error) => console.error('Error:', error));                
            } else { // there is an input file so we send a `multipart/form-data` input
                // we create a `FormData` object to construct our input
                const fd = new FormData();
                // we feed it with the user-selected image (index 0 because a file input can hold several files)
                fd.append('image_url', itemImage[0]);
                // we add the text input
                fd.append('title', itemTitle);
                // we send our POST request
                fetch('/items/add', {
                    method: 'POST',
                    body: fd
                })
                    .then(response => response.json())
                    .then((item) => updateDomWithNewItem(item))
                    .catch((error) => console.error('Error:', error));
            }
        });

    });
Enter fullscreen mode Exit fullscreen mode

The good news is that, when you send multipart/form-data, PHP $_POST and $_FILES are automatically filled; you can check it out by yourself by dumping the contents of these superglobals in the add method of the ItemController => var_dump($_POST); var_dump($_FILES); die();.

So, now you are able to send either JSON or an input with a file in it and you know that PHP will be able to parse it.

What's next ? now let's save this file on a backend disk !

be able to upload files with PHP so they are stored on disk

First, we must allow PHP to adapt in our controller add method based on the type of input (presence of a file or not); this can be done very easily =>

            // getting request input from JSON or `$_POST` array
            // note: adding `true` as a 2nd param to the `json_decode` function allows us to get an associative array
            $itemPayload = count($_POST) <= 0 ? json_decode(file_get_contents('php://input'), true)
                : $_POST;
Enter fullscreen mode Exit fullscreen mode

Then, we need to add to persist an uploaded file on disk if any =>

            // proceeding with file upload if there is a file
            if (count($_FILES) > 0) {
                // choosing a place where to store uploaded files
                $targetDir = "/var/www/public/uploads/";
                $fileName = basename($_FILES["image_url"]["name"]);
                $targetFilePath = $targetDir . $fileName;
                // TODO file validations (size, extension, file name length ...)
                // TODO behavior if file already exists
                move_uploaded_file($_FILES["image_url"]["tmp_name"], $targetFilePath);
            }
Enter fullscreen mode Exit fullscreen mode

Finally, let's create and uploads folder in our public directory where the images will be stored, and also tweak our Dockerfile so that nginx can write in that folder =>

FROM php:8.1.12-fpm
RUN apt update \
    && apt upgrade -y \    
    && apt install -y nginx
RUN docker-php-ext-install pdo_mysql bcmath > /dev/null
# configuring nginx
COPY nginx.conf /etc/nginx/nginx.conf
# create system user ("example_user" with uid 1000)
RUN useradd -G www-data,root -u 1000 -d /home/example_user example_user
RUN mkdir /home/example_user && \
    chown -R example_user:example_user /home/example_user
WORKDIR /var/www
COPY . /var/www/
COPY --from=vendor /app/vendor/ /var/www/vendor
# copy existing application directory permissions
COPY --chown=example_user:example_user ./ /var/www
EXPOSE 80
COPY docker-entry.sh /etc/entrypoint.sh
ENTRYPOINT ["sh", "-c", "php-fpm -D \ 
    && chgrp www-data -R /var/www/public/uploads \
    && chmod -R g+rwx /var/www/public/uploads \
    && nginx -g 'daemon off;'"]
Enter fullscreen mode Exit fullscreen mode

After having killed the application stack and re run docker compose up --build --force-recreate, you should now be able to upload an image on disk !

add the uploaded image url to the item on insert in the db

You should be able to see the uploaded image when going to http://localhost/uploads/<image_name> on your browser. Now let's add this information to our database record.
We can do this after the file has been uploaded with a one-liner =>

$itemPayload['image_url'] = 'uploads/'.$fileName;
Enter fullscreen mode Exit fullscreen mode

Before trying to add an image and see it appear right away in our browser, let's change the Item model so it can add the image url information =>

    public function insert(array $item): int
    {
        $statement = $this->pdo->prepare(
            "INSERT INTO " . self::TABLE . " (`title`, `image_url`) VALUES (:title, :image_url)"
        );
        $statement->bindValue('title', $item['title'], PDO::PARAM_STR);
        $statement->bindValue(
            'image_url', 
            !empty($item['image_url']) ? $item['image_url'] : '', 
            PDO::PARAM_STR
        );
        $statement->execute();
        return (int)$this->pdo->lastInsertId();
    }
Enter fullscreen mode Exit fullscreen mode

Finally, let's update our frontend JS to refresh the DOM with the new image after adding an item =>

    // factorizing updating the DOM with a new item in one place
    const updateDomWithNewItem = (item) => {
        // we create a `li` that will hold our newly retrieved item
        const itemEl = document.createElement('li');
        // we fill this list item with the correct link
        itemEl.innerHTML = `<a href="/items/show?id=${item.id}">${item.id}. ${item.title}</a>`;
        // if there is an image url, we add the element to show it
        if (item.image_url.trim().length > 0) {
            itemEl.innerHTML += ` <img src="${item.image_url}" alt="${item.title} associated image">`;
        }
        // we update the DOM
        document.getElementById('items_list').appendChild(itemEl);
    }
Enter fullscreen mode Exit fullscreen mode

You can now add items with or without attached file using PHP and Javascript !

Please dont hesitate to give me feedback in the comments and, until then, goodbye 👋

Top comments (0)