T ToutadminDocumentation

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#

ItemVersionWhy
PHP8.1 or newerUnion types, enum, readonly, never: the code uses them.
pdo_sqliteThe whole database. Without it, nothing runs.
mbstringCorrect string handling across sixteen languages.
opensslrecommendedEncryption of secrets in the database (SMTP passwords, off-site tokens).
curlrecommendedOutgoing webhooks and sending backups to a third party.
intlrecommendedDates 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.

No dependencies

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
The web root points at 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,
];
The instance key

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#

PathModeWho writes
data/0770The web server, and it alone
data/app.sqlite0660Created by the installation
config.php0640Nobody — read-only for the server
app/, public/0755Nobody — 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 doesHow often
Closes the accounts whose contract has run outevery pass
Issues the invoices of subscriptions that have come dueevery pass, never invoicing twice
Turns deadlines into notificationsevery pass, deduplicated
Purges read notifications and the audit logaccording to the chosen retention
Collects the accounting mailbox (IMAP)if collection is configured
Drains the webhook queue, with its retriesevery pass
Creates the automatic backup and sends it off-sitewhen 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.

On hosting with no cron

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:

  1. The prerequisites, checked and displayed — what blocks is marked as such.
  2. The install token, if one is configured.
  3. The organisation: the name shown everywhere, and the number of annual leave days granted to each new non-freelance employee.
  4. The default language of the instance, among the 16.
  5. 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.