Installing the PHP edition
The PHP edition drops onto shared hosting as it is: no Composer, no package manager, no service to keep running. One folder, one configuration file, one scheduled task. This tutorial goes from an empty folder to a production instance, including what most guides leave out: permissions, TLS, the scheduled task, backups and upgrades.
What you need#
| Item | Version | Why |
|---|---|---|
| PHP | 8.1 or newer | Union types, enum, readonly, never: the code uses them. |
pdo_sqlite | — | The whole database. Without it, nothing runs. |
mbstring | — | Correct string handling across sixteen languages. |
openssl | recommended | Encryption of secrets in the database (SMTP passwords, off-site tokens). |
curl | recommended | Outgoing webhooks and sending backups to a third party. |
intl | recommended | Dates written as in the Node edition. Without it, month names come from the dictionaries. |
The installation wizard checks all of this and shows it on screen before letting you continue: what is blocking stops the installation, what is optional is flagged without blocking.
There is nothing to install with composer: the IMAP reader, the
tar.gz archive, the PDF, the CSV, the QR code and the HTTP client are all
written inside the product. That is what makes it possible to drop it onto hosting where
you have no command line.
1. Upload the files#
Get the PHP edition archive, then upload it. The folder looks like this:
toutadmin/
├── app/ the code (never served by the web server)
│ ├── Core/ kernel: routing, database, sessions, security
│ ├── Modules/ business: HR, finance, projects, quality…
│ ├── Controllers/
│ ├── views/ templates
│ ├── locales/ the sixteen dictionaries
│ └── schema.sql the schema, identical to the Node edition's
├── public/ ← the web root points here, and nowhere else
│ ├── index.php the front controller
│ ├── css/ js/
├── data/ database, vault, signature book, received documents, backups
├── tools/ cron.php, check-keys.php
├── tests/
├── config.sample.php
└── VERSION
public/, not at the folder
This is the most important point on this page. If the server serves the whole folder,
data/app.sqlite becomes downloadable by anyone: the entire database,
password hashes and vault included. The data/ folder sits above
public/ on purpose.
On shared hosting#
Two cases arise. If your host lets you choose a domain's root (cPanel, Plesk, and most
European hosts in "root folder" mode), upload the folder outside www/ and
point the domain at toutadmin/public.
If the root is imposed — often www/ or public_html/ — upload the
contents of public/ into it and the rest above, then fix the
path at the top of public/index.php:
www/ ← imposed root
├── index.php
├── css/ js/
toutadmin/ ← the rest, out of the web's reach
├── app/
├── data/
└── config.php
// www/index.php — the line to adjust
require_once __DIR__ . '/../toutadmin/app/bootstrap.php';
As a last resort, if you can put nothing above the root, the .htaccess file
shipped inside data/ already denies access. That is a belt, not a solution:
always prefer the folder outside the root.
2. Write the configuration#
cp config.sample.php config.php
Then open config.php:
<?php
return [
// SQLite database and data folder: outside the web root.
'db_path' => __DIR__ . '/data/app.sqlite',
'data_dir' => __DIR__ . '/data',
// A key of this instance's own. Generate it once:
// php -r "echo bin2hex(random_bytes(32));"
'session_secret' => '…',
// Install token: see the next step.
'install_token' => '',
// Public address, for the links sent by email.
'base_url' => 'https://intranet.example.com',
// Only switch on behind a trusted proxy.
'trust_proxy' => false,
// Ceilings: login attempts per quarter-hour and per address,
// requests per minute and per address.
'login_rate_limit' => 10,
'global_rate_limit' => 300,
// Expiry: inactivity, then an absolute duration no activity extends.
'session_idle_minutes' => 60,
'session_max_hours' => 12,
];
It seals the signature book's signatures and encrypts secrets in the database. Losing it
invalidates signatures. If you leave the sample value in place, the product generates one
by itself in data/.instance-key (permissions 0600) — remember to
back that file up with the rest.
You can also keep the configuration elsewhere: the environment variable
TOUTADMIN_CONFIG points at another file.
3. Set an install token#
Between uploading the files and your visit to the wizard, the instance belongs to whoever finds it: the first to arrive creates the administration account. The window is short; it is enough. So set a random value before going live:
php -r "echo bin2hex(random_bytes(16));"
# then in config.php:
'install_token' => 'c3f1…',
The wizard asks for it, compares it in constant time — an ordinary comparison would let the token be guessed character by character — and logs every refusal. Without the token, nothing is created. Once the instance is installed, the wizard closes by itself: the token can stay, it serves no further purpose.
4. Permissions#
| Path | Mode | Who writes |
|---|---|---|
data/ | 0770 | The web server, and it alone |
data/app.sqlite | 0660 | Created by the installation |
config.php | 0640 | Nobody — read-only for the server |
app/, public/ | 0755 | Nobody — read-only |
# example on a server where PHP runs as www-data
sudo chown -R you:www-data /var/www/toutadmin
sudo find /var/www/toutadmin -type d -exec chmod 750 {} \;
sudo find /var/www/toutadmin -type f -exec chmod 640 {} \;
sudo chmod 770 /var/www/toutadmin/data
sudo chmod 750 /var/www/toutadmin/public
SQLite also writes sibling files (-wal, -shm): it is the
folder that must be writable, not just the database.
5. The web server#
Nginx#
server {
listen 443 ssl http2;
server_name intranet.example.com;
root /var/www/toutadmin/public; # and nothing else
index index.php;
ssl_certificate /etc/letsencrypt/live/intranet.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/intranet.example.com/privkey.pem;
client_max_body_size 20M; # vault uploads, received documents
location / {
try_files $uri $uri/ /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Uploaded files are never served directly: they go through a route
# that checks the session and the hash.
location ~ ^/(data|app|tools|tests)/ { deny all; }
}
server {
listen 80;
server_name intranet.example.com;
return 301 https://$host$request_uri;
}
Apache#
<VirtualHost *:443>
ServerName intranet.example.com
DocumentRoot /var/www/toutadmin/public
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/intranet.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/intranet.example.com/privkey.pem
<Directory /var/www/toutadmin/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
The public/.htaccess file that ships with the product already sends every
address to index.php. On Apache shared hosting, that is all you need.
TLS#
The product sets the Strict-Transport-Security header as soon as the connection
is encrypted, and never in the clear: announced from an unencrypted page it would not be
read, and it would lock a local trial into https for six months. On a server
you administer:
sudo certbot --nginx -d intranet.example.com
Behind a proxy or load balancer that terminates TLS, set
'trust_proxy' => true — and only there: otherwise anyone could announce the
IP address and protocol of their choice in a header.
6. The scheduled task#
A PHP site only runs during a request. What the Node edition does in its hourly sweep — eight operations — happens here from the host's cron:
* * * * * /usr/bin/php /var/www/toutadmin/tools/cron.php >> /var/log/toutadmin-cron.log 2>&1
| What the task does | How often |
|---|---|
| Closes the accounts whose contract has run out | every pass |
| Issues the invoices of subscriptions that have come due | every pass, never invoicing twice |
| Turns deadlines into notifications | every pass, deduplicated |
| Purges read notifications and the audit log | according to the chosen retention |
| Collects the accounting mailbox (IMAP) | if collection is configured |
| Drains the webhook queue, with its retries | every pass |
| Creates the automatic backup and sends it off-site | when the interval has elapsed |
Calling the script more often than the configured interval does not back up more often:
nothing fires before it is due. On hosting that limits cron to one pass every fifteen
minutes, replace * * * * * with */15 * * * * — the webhooks will
simply go out with that delay.
Some entry-level hosts offer none. An HTTP call from a third-party service (an uptime
monitor and the like) will not do: cron.php refuses to run anywhere but on
the command line, precisely so that a public address cannot trigger a backup. In that
case, run the backup by hand from the Backups screen.
7. The wizard#
Open your domain. As long as no account exists, every address redirects to
/installation. The wizard fits on one page:
- The prerequisites, checked and displayed — what blocks is marked as such.
- The install token, if one is configured.
- The organisation: the name shown everywhere, and the number of annual leave days granted to each new non-freelance employee.
- The default language of the instance, among the 16.
- The administration account: address and a password of at least twelve characters.
Everything is written in a single transaction. As soon as an account
exists, /installation redirects to the login page: the wizard has closed
itself, there is no file to delete by hand.
8. Check that it all works#
# the tests, on the machine (they do not write into your database)
php tests/run.php
# the translations: no screen uses a key missing from the dictionaries
php tools/check-keys.php
Then, in the interface: create a member, log in as them, upload an attachment, run a manual backup and verify it from the Backups screen. Those four gestures touch the database, the files, the rights and the archive — that is to say, everything the hosting must be able to do.
9. Upgrading#
# 1. a backup first, always
php tools/cron.php # or the Backups screen
# 2. replace the code, not the data
# app/ public/ tools/ tests/ VERSION ← replaced
# data/ config.php ← left as they are
# 3. the database migrates itself on the first request
The schema evolves through idempotent migrations: replaying the upgrade breaks nothing. A Node-edition instance and a PHP-edition instance share the same schema — 142 tables, 1311 columns — and the same passwords: a database moves from one to the other with no conversion.
10. Backing up, properly#
The tar.gz archive carries the database (copied through
VACUUM INTO, so consistent even during a write) and the five file folders:
profile photos, received CVs, vault, signature book, received documents. Every file in it
carries its SHA-256 hash, verified on restore.
An archive that stays on the server it protects protects nothing: configure a remote destination (FTPS or Google Drive) from the Backups screen. The detail is on the Backups and restore page.
Try it locally first#
cp config.sample.php config.php
php -S localhost:8000 -t public public/index.php
PHP's built-in server is enough to explore the product. It is not suitable for production: a single process, no TLS, no load limiting.
Toutadmin documentation — built on 2026-09-13. A standalone site, independent of the software.