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'sindex
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 %}
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>
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);
});
});
});
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 =>
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 !
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.
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');
}
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
});
*/
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');
}
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 %}
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');
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 %}
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>
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));
}
});
});
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;
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);
}
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;'"]
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;
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();
}
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);
}
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)