How I bundled SLiMS, PHP, and MariaDB into a double-clickable Windows application for a school librarian.
So, you sell people peace of mind.
Then you leave them with a permanent headache: keep paying your cloud-powered subscription just to keep the application—and sometimes their own data—available.
Fine. Maybe you sell the software for a one-time fee instead.
But now the customer needs to hire IT support to install Linux packages, configure MySQL, manage Docker containers, open ports, create database users, and figure out why the service did not restart after a power outage.
I call bullshit.
If your “independent alternative” still forces a small organization to depend on recurring payments or permanent technical support, you are not necessarily better than the corporations you claim to be fighting.
Apps used to work like this:
- Download the installer.
- Double-click it.
- Use the application.
What happened to that?
Even if you send technical staff for the first installation, wouldn't it be better if the user could reinstall the application themselves? Wouldn't it be nice if recovering from a dead PC meant restoring a backup instead of performing acrobatics with APT commands, Docker Compose files, database credentials, and half-remembered Stack Overflow answers?
This article is about turning a traditional PHP and MariaDB web application into something a school librarian can install by double-clicking one .exe.
Not a SaaS platform.
Not a Docker deployment.
Not a Kubernetes cluster for managing 3,000 books.
A Windows application.
The actual problem
My wife was going to manage a junior high school library.
We needed a library management system, and building one from scratch would have been silly. There are already mature open-source applications for this, including SLiMS, or Senayan Library Management System.
SLiMS already provides the important things:
- Bibliographic cataloging
- Membership management
- Circulation and loan records
- Barcodes
- Reports
- Administration
- Backup-related tools
- An established community and years of development
The application itself was not the problem.
Delivery was.
SLiMS is a PHP application backed by MySQL or MariaDB. A normal deployment expects some combination of PHP, a web server, a database server, and somebody who understands how to keep them alive.
Docker makes this significantly easier for developers.
A school librarian is not a developer.
Telling her to install Docker Desktop, understand containers, and remember that closing the wrong thing may stop the library system is not a solution. It is exporting my technical convenience to the user.
The goal became simple:
Can we make a PHP and MariaDB application behave like an ordinary Windows desktop application?
The desired experience
The entire installation process should be:
- Download
Perpustakaan-SMP-Setup.exe. - Double-click it.
- Confirm the installation.
- Enter the library name and administrator credentials.
- Start managing books.
On subsequent uses:
- Double-click the Desktop shortcut.
- Wait for the startup screen.
- The application opens in the browser.
The user should never need to know that PHP and MariaDB are involved.
Those are implementation details. Implementation details belong to the developer.
The architecture
I did not compile PHP and MariaDB into one magical binary. Instead, I created a native launcher and bundled portable Windows distributions of the required components.
The installed application looks roughly like this:
PerpustakaanSMP.exe
runtime/
├── php/
│ ├── php.exe
│ ├── php.ini
│ └── ext/
├── mariadb/
│ └── bin/
│ ├── mariadbd.exe
│ ├── mariadb.exe
│ └── mariadb-admin.exe
└── router.php
app-template/
└── SLiMS source code
PerpustakaanSMP.exe is a small native launcher written in Go.
Its job is to:
- Initialize the application on first run
- Generate local database credentials
- Initialize MariaDB
- Create the SLiMS database and database user
- Generate the SLiMS database configuration
- Start MariaDB
- Start PHP's local web server
- Display startup progress
- Open the browser
- Monitor both child processes
- Shut everything down safely
- Create and restore backups
From the user's perspective, it is one application.
From the operating system's perspective, it is a supervised local PHP stack.
No administrator privileges and no Windows service
I deliberately avoided installing MariaDB as a global Windows service.
Services create another collection of problems:
- They may require administrator privileges.
- They keep running when the application is not being used.
- They introduce service names and global configuration.
- They can conflict with existing MySQL installations.
- Troubleshooting them often requires administrative tools.
Instead, the launcher starts its private MariaDB process only while the library application is running.
MariaDB listens on:
127.0.0.1:3307
PHP listens on:
127.0.0.1:8123
Both are bound to the loopback interface. They are not exposed to the school network or the internet.
This version is intended for a single library computer. A multi-computer deployment would require a different security and networking model.
Separating the application from its data
A reinstallation must never destroy the library database.
Program files and user data are therefore stored separately.
Program files:
%LOCALAPPDATA%\Programs\PerpustakaanSMP\
Persistent data:
%LOCALAPPDATA%\PerpustakaanSMP\
├── app\
├── database\
├── logs\
├── sessions\
├── backups\
└── secrets.conf
The installer is allowed to replace:
- The launcher
- PHP
- MariaDB binaries
- The PHP configuration
- The router
- The bundled SLiMS application template
It does not replace the MariaDB data directory.
Uploaded files, images, repositories, and application configuration are preserved during application updates.
This means the user can run a newer installer over the existing installation without recreating the administrator or losing catalog data.
First-run provisioning
On the first launch, the native launcher performs the work normally left to a system administrator.
It generates random credentials for:
- The MariaDB root account
- The dedicated SLiMS database account
It then initializes the local MariaDB data directory and creates:
CREATE DATABASE slims
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
A dedicated application account is granted access only to that database.
The generated configuration is equivalent to:
Host: 127.0.0.1
Port: 3307
Database: slims
Username: slimsapp
Password: randomly generated
The librarian never needs to see or manage these credentials.
They are generated because the software needs them, not because the user should become a part-time database administrator.
Making a PHP application behave correctly without Apache
SLiMS normally runs behind Apache or another traditional web server. To reduce the bundle's complexity, I used PHP's built-in server:
php.exe `
-S 127.0.0.1:8123 `
-t <slims-directory> `
router.php
The custom router handles:
- PHP entry points
- Directory index files
- Static assets
- SLiMS routes
- A private health-check endpoint
This sounds straightforward. It was also the source of some wonderfully deceptive bugs.
Bug one: the working directory
The first version could display the SLiMS installer. After creating the administrator, however, the admin page crashed.
The log revealed:
Failed opening required '../sysconfig.inc.php'
The file existed.
The problem was the current working directory.
Some older PHP applications use relative includes based on the assumption that Apache executes a script from its directory. My router was executing the requested file while the working directory still pointed to the application root.
The fix was to temporarily change the directory before requiring the script:
$previousDirectory = getcwd();
chdir(dirname($script));
try {
require basename($script);
} finally {
chdir($previousDirectory);
}
The missing file error disappeared.
Then we got:
Invalid access module
Progress!
Bug two: PHP include scope
My first router implementation placed the require inside a helper function:
function runPhpScript(string $script): bool
{
require $script;
return true;
}
That changes the scope in which the included file executes.
SLiMS initializes configuration such as $sysconf during its bootstrap. Some older modules later access it using:
global $sysconf;
Because the bootstrap had run inside my helper function, $sysconf was local to that function rather than part of the global scope.
The files were now found, but the application configuration effectively disappeared when security modules tried to access it.
The correct router executes require at the router's top-level scope:
$previousDirectory = getcwd();
$_SERVER['SCRIPT_FILENAME'] = $script;
chdir(dirname($script));
try {
require basename($script);
} finally {
chdir($previousDirectory);
}
return true;
Compatibility work is often like this. The hard part is not starting PHP. The hard part is reproducing environmental assumptions accumulated over many years.
A port being open does not mean the application is healthy
Initially, the launcher checked whether port 8123 was open.
That was insufficient.
A process can own the port while the application behind it is broken. Another unrelated process could also be using the same port.
I added a private endpoint:
/__perpustakaan_health
It returns a fixed value:
perpustakaan-smp-ok
The launcher considers SLiMS ready only if it receives:
- HTTP status
200 - The expected response body
- Within a short timeout
The browser is opened only after this check succeeds.
Startup feedback matters
Starting PHP is quick. Starting a database, initializing its system tables, creating accounts, and provisioning an application is not always quick.
The first build opened the browser only after everything was ready.
Technically, that worked.
From the user's perspective, double-clicking the shortcut appeared to do nothing.
Even I could not immediately tell whether the application was starting.
The launcher now starts a temporary progress page first:
Checking application components…
Preparing SLiMS files…
Preparing the local database…
Starting MariaDB…
Starting SLiMS…
When the health check passes, that page redirects to the actual application.
A loading screen is not merely decoration. It is confirmation that the user's action had an effect.
Dealing with stale and duplicate processes
Another early build produced contradictory messages:
The application is not running.
Followed by:
An old process is still running.
Both could technically be true according to different checks.
One check inspected the HTTP server. Another found an old PID or database process. Neither understood the application as one supervised unit.
The revised launcher records the PIDs of:
- The launcher
- PHP
- MariaDB
It also verifies that a recorded PID actually belongs to the expected executable path.
This matters because operating systems reuse process IDs. Blindly killing a stale PID could terminate an unrelated process.
On shutdown, the launcher:
- Signals its existing controller process.
- Stops accepting application requests.
- Uses
mariadb-admin shutdownfor a clean database shutdown. - Waits for the child processes to exit.
- Force-terminates only confirmed application-owned processes when necessary.
- Removes stale PID files.
Double-clicking the shortcut twice no longer creates two database servers. The second launcher waits for the first one and displays startup progress.
Packaging everything into one installer
The staged application is compressed into a ZIP archive.
A second Go program embeds that archive directly:
//go:embed payload.zip
var payload []byte
That program becomes:
Perpustakaan-SMP-Setup.exe
The installer:
- Stops an existing installation safely
- Extracts the embedded payload
- Creates Desktop and Start Menu shortcuts
- Registers the backup file association
- Starts the application
The distributed artifact is one approximately 180 MB executable.
After installation, its components are extracted normally. This is conceptually similar to how Electron applications ship Chromium or how games bundle their required runtimes.
“One executable” is a delivery experience, not necessarily a claim that every dependency was statically linked into one process.
Backup must also belong to the user
Local-first software should not replace subscription dependency with “pray that the hard disk never dies.”
The launcher creates a portable backup containing:
- A database dump
- Uploaded files
- Images
- Repository contents
- Application configuration
- Backup metadata
The backup uses a custom .slimsbackup extension registered with Windows. Restore can therefore be initiated by double-clicking the backup file.
The default backup destination is the user's Documents directory, where it can be copied to a flash drive, external disk, NAS, or whichever storage the school actually controls.
Local-first does not mean backup-free.
It means the user decides where the backup lives.
What I learned
If you want to turn a server-oriented application into a user-installable local product:
- Treat PHP, the database, and the web application as private implementation details.
- Keep program files separate from persistent user data.
- Bind local services to loopback unless network access is explicitly required.
- Generate internal credentials automatically.
- Supervise the complete process lifecycle.
- Validate process ownership before terminating anything.
- Use real health checks, not port checks.
- Show progress immediately after the user clicks.
- Expect older applications to depend on working directories and global scope.
- Make backup and restore part of the product, not an appendix in the documentation.
- Test upgrade and failure paths, not just fresh installations.
- Respect every upstream license in the bundle.
The result may not be architecturally fashionable.
That is fine.
The librarian does not care whether the application uses containers, microservices, serverless functions, or an enterprise-grade service mesh.
She wants to lend books.
And honestly, that is a much better product requirement than “must look impressive in an architecture diagram.”

Top comments (0)