Phriction Welcome in gitpull.it, a Phabricator instance! Welcome in suckless-php History Version 15 vs 21
Version 15 vs 21
Version 15 vs 21
Edits
Edits
- Edit by valerio.bozzolan, Version 21
- Sep 19 2023 13:35
- Edit by valerio.bozzolan, Version 15
- Mar 9 2022 12:57
- ·more shit
Edit Older Version 15... | Edit Current Version 21... |
Content Changes
Content Changes
# suckless-php: Another KISS framework that sucks less
You discovered `suckless-php`! My amazing //keep-it-simple-and-stupid// laser cannon that I used to develop dozen of very-different Content Management Systems made from scratch in my life.
## Why using suckless-php
With this framework I was able to create a lot of relational content management systems with these features:
* stateless (it means it's very easy to scale)
* efficient (very tiny footprint both in disk and memory)
* [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery)-safe
* on demand resource loader (e.g. DB connection is instantiated only if you use it etc.)
* object-oriented query builder (supporting also very complex queries)
* micro-ORM implementation
* associative options WordPress-style
* database table prefix (you can run multiple instances in a single database)
* login, user roles, user permissions, role inheritance
* secure file uploads (MIME type checks etc.)
* multi-language (using the widely used GNU Gettext native or high-level)
Everything is made in just ~15 short PHP files. It's __damn__ small for all these features!
## When not using suckless-php
* do not use `suckless-php` if you hate programming
* do not use `suckless-php` if you expect something complete with all the bells and whistles that you can give to a _luser_ to manage a website in every it's damn component without a programmer (in that case you need a shitty, scary and giant complete content management system, you need to host something like 12MB of source code, often that you must fill with other crapware plugins, etc. In this case the `suckless-php` framework is not your solution. This framework is incompatible with people who love waste of resources, computation and global warming).
## Requirements
You should have basic knowledges about a webserver with PHP and MySQL (or MariaDB obviously).
Run your favorite webserver. I use a simple (G)LAMP machine:
* GNU/Linux
* Apache or nginx
* PHP
* MySQL or MariaDB
This can't be a guide for how to install a webserver. Anyway if it's your first time, try with a clean Debian GNU/Linux installation, and then:
```
sudo apt install apache2 mysql-server libapache2-mod-php php-mysql
```
If you plan to allow users to upload files, install the `libmagic-dev` package.
That's all.
## Framework installation
The framework can be saved inside a read-only directory of your choice.
I usually want it in the `/usr/share/php/suckless-php` path, in order to keep it in one place for every project:
```
sudo git clone https://gitpull.it/source/suckless-php.git /usr/share/php/suckless-php
```
But instead you may want the framework inside your project or whenever you want. It will also work.
## Quick start project
We have created a template project (boilerplate) for you. You can just try it and then come back here for more instructions!
Boilerplate:
https://gitpull.it/source/suckless-php-boilerplate/
### Quick start documentation
You can configure everything, but let's assume that:
* `/var/www` is your DocumentRoot of your webserver's virtual host
* `/var/www/project` will contain your example website project (HTML, CSS, JavaScript, PHP files)
* `http://localhost/project` is what you want to visit to see that project
* `/usr/share/php/suckless-php/load.php` should exist
Spoiler! In the next steps you will create:
* `/var/www/project/load.php` - your database configuration file
* `/var/www/project/load-post.php` - your website configuration file
* `/var/www/project/index.php` - your homepage
Let's start creating a MySQL database and a MySQL user with a password.
Go to your project (`/var/www/project`) and create a configuration file. Call it `load.php` and save this:
```
<?php
// database credentials
$username = 'Your database username';
$password = 'The password of your database user';
$database = 'Your database name';
$charset = 'utf8mb4';
$location = 'localhost';
// table prefix, if any
$prefix = '';
// absolute pathname to this directory without trailing slash
define( 'ABSPATH', __DIR__ );
// relative URL to this directory (or an empty string) without trailing slash
define( 'ROOT', '/project' );
// load the framework
require '/usr/share/php/suckless-php/load.php';
```
Now create also a `load-post.php` file with something like:
```
<?php
// this file is automagically called after something called your 'load.php'
// in this file you can use every suckless-php function to describe your project
// register some custom privileges
register_permissions('subscribed', [
'add-comment',
'page-vote',
] );
// the superuser inherit from the subscribed and has also other permissions
inherit_permissions('superadmin', 'subscribed', [
'do-wonderful-things',
] );
// register some useful JavaScript/CSS files to be used later
register_js( 'jquery', 'media/jquery-min.js');
register_css( 'my-style', 'media/style.css');
// register some menu entries
// etc. (we will see features next)
```
Now create also your homepage in `index.php`:
```
<?php
// autoload everything
require 'load.php';
// test a query
$row = query_row('SELECT NOW() AS now');
// print something
echo "If the database credentials works, the time is: {$row->now}";
```
Now just visit `http://localhost/project/` to check if everything works.
## Understand what happens
Just as a note. With the above example, when you visit `http://localhost/project/` this happens:
1. The user requests `http://localhost/project`
2. `/var/www/project/index.php` is executed and then it requires your 'load.php' (see the comment `// autoload everything` above)
3. `/var/www/project/load.php` is executed and loads the framework (see the comment `/ load the framework` above)
4. `/usr/share/php/suckless-php/load.php` is executed and quits, requiring your `load-post.php`
5. `/var/www/project/load-post.php` is executed and ends
6. `/var/www/project/index.php` continue it's execution as normal
7. You see __If the database credentials works, the time is: ...__
That's all.
As you note, you decide whenever you want to use the framework or not. Just put a `require 'load.php';` as the first line of your PHP page to use the framework stuff.
## Configure database
If you want these features:
* login/logout
* user roles and permissions
* associative options
Just import the example database schema provided in the `examples` folder of this framework. Adjust the table prefixes for your needs:
* [example-schema.sql](https://gitpull.it/source/suckless-php/browse/master/examples/extras/example-schema.sql)
## All available configurations for your `load.php` file
| Config | Type | Data type | Mandatory | Description | Example |
|-------------|-------|-----------|------------|--------------------|---------|
| `ABSPATH` | const | string | Mandatory | Filesystem path. No end slash. | `"/var/www/foo"` |
| `ROOT` | const | string | Mandatory | URL path. No end slash. | `"/foo"` |
| `$username` | var | string | Optional | DB user. | `"foo"` |
| `$password` | var | string | Optional | DB user password. | `"strong"` |
| `$database` | var | string | Optional | DB name. | `"FOO"` |
| `$location` | var | string | Optional | DB hostname or IP. | `"localhost"` |
| `$prefix` | var | string | Optional | DB tables prefix. | `"foo_"` |
| `$charset` | var | string | Optional | DB default charset. | `"utf8mb4"` |
| `PROTOCOL` | const | string | Optional | Preferred web protocol for URLs. | `https://` |
| `DOMAIN` | const | string | Optional | Preferred domain for URLs. | `example.com` |
| `URL` | const | string | Optional | Preferred base URL. | `https://foo.com/foo` |
| `DEBUG_QUERIES` | const | bool | Optional | Queries are logged. | `true` |
| `DEBUG` | const | bool | Optional | Print log lines to output. | `true` |
Example of `const` customization:
```
define( "ABSPATH", "/var/www/foo" );
```
Example of `var` customization:
```
$database = "FOO";
$username = "foo";
$password = "strong";
$location = "localhost";
$prefix = "foo_";
```
## Troubleshooting
You should know how to inspect your webserver logs. Usually just:
```
sudo tail -f /var/log/apache2/error.log
```
In the next examples you may also want to debug your queries. Just put these inside your `/var/www/project/load.php`:
```
define( 'DEBUG_QUERIES', true );
define( 'DEBUG', true );
```
This is useful __only during development__ to show on video every database query before its execution. Remember to force to `false` in production.
## Example of JSON API
Creating a JSON API page is very simple. This is an example that can be saved as `/var/www/project/api.php`
```
<?php
// autoload everything
require 'load.php';
// do some pre-conditions
if( empty( $_GET['ping'] ) ) {
// die with an HTTP status code 400 Bad Request and show a message
json_error( 400, 'missing-ping', "Please specify the 'ping' argument in your query string" );
}
json( [
'success' => true,
'pong' => $_GET['ping'],
] );
```
Now try to visit:
* `http://localhost/project/api.php`
* `http://localhost/project/api.php?ping=foo`
Note that to optimize the data transfer, `false` and `null` values and empty objects will be automagically removed from the JSON. It's a feature. Handle these cases from JavaScript.
Note that in this example you do not use the database so no database connection was established.
## Execute a database query
Note that the database connection is established automagically only if you need it. Now some examples.
### Execute a query using the Query builder (object-oriented)
```
<?php
// autoload everything
require 'load.php';
// get a pure array of rows from an example 'post' database table
$posts = ( new Query() )
->from( 'post' )
->queryResults();
// do something with your data
foreach( $posts as $post ) {
echo $post->id;
}
```
```
<?php
// autoload everything
require 'load.php';
// get a Generator of rows from an example 'post' database table (see PHP generators)
$generator = ( new Query() )
->from( 'post' )
->queryGenerator();
// do something with your data
foreach( $posts as $post ) {
echo $post->id;
}
```
Another more complex example:
```
<?php
// autoload everything
require 'load.php';
// get a pure array of rows from an example 'post' database table
// where the 'post_author_id' = $author_id (int comparison)
// where the 'post_status' can be 'private' or 'stub' or 'deleted'
// where the 'post_date' is before the current MySQL date
// LEFT join to the user ON (user.user_ID = post.post_author_id)
$posts = ( new Query() )
->from( 'post' )
->whereStr( 'post_title', $title )
->whereInt( 'post_author_id', $author_id )
->whereSomethingIn( 'post_status', [ 'private', 'stub', 'deleted' ] )
->where( 'post_date < NOW()' )
->joinOn( 'LEFT', 'user', 'user.user_ID', 'post.post_author_id' )
->queryResults();
// do something with your data
foreach( $posts as $post ) {
echo $post->id;
}
```
Note that a method that gives a `generator` is more efficient over a method that returns a complete array of results, if:
* You do not need the full list (you need just one at time)
* You do not need to read the list multiple times.
## Insert
Object-oriented way:
```
<?php
// autoload everything
require 'load.php';
( new Query() )
->from( 'post' )
->insertRow( [
'post_ID' => 1,
'post_title' => 'hello',
] );
```
Declarative way:
```
<?php
// autoload everything
require 'load.php';
insert_row( 'post', [
'post_ID' => 1,
'post_title' => 'hello',
] );
```
## Delete
```
<?php
// autoload everything
require 'load.php';
// build a delete query and run
( new Query() )
->from( 'post' ) // DELETE FROM post
->whereStr( 'post_author', 'jhon' ) // WHERE post_author = 'jhon'
->whereInt( 'post_stub', 1 ) // AND post_stub = 1
->whereIsNull( 'post_field' ) // AND post_field IS NULL
->whereIsNotNull( 'post_z' ) // AND post_z IS NOT NULL
->delete();
```
### Query (declarative way)
This is useful to run strange queries that does not return stuff (remember to sanitize your data):
```
<?php
// autoload everything
require 'load.php';
query( "ALTER TABLE ADD COLUMN `hello` ..." );
```
### Select rows (declarative, full list)
```
<?php
// autoload everything
require 'load.php';
// results as a pure array
$posts = query_results( "SELECT post_title FROM wp_post" );
// as example, let's expose them in JSON
json( $posts );
```
Note that `query_results()` is better over `query_generator()` if:
* You need the full list (e.g. to count them, etc.)
* You want to read the list multiple times
### Select rows (declarative, generator)
```
// generator of results
$posts = query_generator( "SELECT post_title FROM wp_post" );
if( $posts->valid() ) {
foreach( $posts as $post ) {
echo $post->post_title;
}
}
```
Note that `query_generator()` is better over `query_results()` if:
* You do not need the full list (but just one at time)
* You do not need to read the list multiple times.
## Database row → object (mapping)
```
<?php
/**
* Declaration of my custom class
*/
class Post extends Queried {
/**
* Table name without table prefix
*/
const T = 'post';
/**
* An example method
*
* @return string
*/
public function getTitle() {
return $this->post_title;
}
}
// now you have a query builder
$post = Post::factory()
->whereInt( 'post_ID', 1 )
->queryRow();
// do stuff
if( $post ) {
// this method exists
$title = $post->getTitle();
}
```
## File upload
File uploading is one of the most painful stuff ever full of security nightmares. We tried to keep it simple and secure.
This is just an example:
```
<?php
// autoload everything
require 'load.php';
$uploader = new FileUploader( 'my-filename', [
'category' => [ 'image', 'video' ],
'max-filesize' =>10000000, // 10~ megabytes
] );
// check if the user uploaded the file
if( $uploader->fileChoosed() ) {
// save the file somewhere
$ok = $uploader->uploadTo( '/var/www/directory/name' );
// print an error message
if( !$ok ) {
die( $uploader->getErrorMessage() );
}
}
?>
<form method="post" enctype="multipart/form-data">
<input type="file" name="my-filename" />
<button type="submit">Submit</button>
</form>
```
Some available file categories:
* `image`
* `audio`
* `video`
* `document`
Check the class [[ https://gitpull.it/source/suckless-php/browse/master/class-MimeTypes.php | class-MimeTypes.php ]] for further details about the default categories.
You can use `register_mimetypes( $category, $mimetypes )` inside your `load-post.php` to register additional mime types to existing categories or to add additional categories.
## CSRF protection
To both identify a form and secure it against [Cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) you can use `form_action()` and `is_action()`:
```
<?php
// autoload everything
require 'load.php';
// check if the user submitted the form
if( is_action( 'save-user' ) ) {
// do something with the value
// $_POST['username']
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Example save user</title>
</head>
<body>
<form method="post">
<?php form_action( 'save-user' ) ?>
<input type="text" name="username" />
<button type="submit">Click to save</button>
</form>
</body>
</html>
```
## Example of login page
```
<?php
// autoload everything
require 'load.php';
if( is_action( 'try-login' ) ) {
login();
}
if( is_logged() ) {
echo "Welcome!";
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Example save user</title>
</head>
<body>
<form method="post">
<?php form_action( 'try-login' ) ?>
<input type="text" name="user_uid" />
<input type="password" name="user_password" />
<button type="submit">Login</button>
</form>
</body>
</html>
```
## Unit tests
```
phpunit --bootstrap=phpunit/load.php --testdox phpunit
```
## License
Copyright (c) 2015-2022 [Valerio Bozzolan](http://boz.reyboz.it/)
This is a **Free** as in **Freedom** project. It comes with ABSOLUTELY NO WARRANTY. You are welcome to redistribute it under the terms of the **GNU General Public License v3+**.
# suckless-php: Another KISS framework that sucks less
You discovered `suckless-php`! My amazing //keep-it-simple-and-stupid// laser cannon that I used to develop dozen of very-different Content Management Systems made from scratch in my life.
## Why using suckless-php
With this framework I was able to create a lot of relational content management systems with these features:
* stateless (it means it's very easy to scale)
* efficient (very tiny footprint both in disk and memory)
* [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery)-safe
* on demand resource loader (e.g. DB connection is instantiated only if you use it etc.)
* object-oriented query builder (supporting also very complex queries)
* micro-ORM implementation
* associative options WordPress-style
* database table prefix (you can run multiple instances in a single database)
* login, user roles, user permissions, role inheritance
* secure file uploads (MIME type checks etc.)
* multi-language (using the widely used GNU Gettext native or high-level)
Everything is made in just ~15 short PHP files. It's __damn__ small for all these features!
## When not using suckless-php
* do not use `suckless-php` if you hate programming
* do not use `suckless-php` if you expect something complete with all the bells and whistles that you can give to a _luser_ to manage a website in every it's damn component without a programmer (in that case you need a shitty, scary and giant complete content management system, you need to host something like 12MB of source code, often that you must fill with other crapware plugins, etc. In this case the `suckless-php` framework is not your solution. This framework is incompatible with people who love waste of resources, computation and global warming).
## Requirements
You should have basic knowledges about a webserver with PHP and MySQL (or MariaDB obviously).
Run your favorite webserver. I use a simple (G)LAMP machine:
* GNU/Linux
* Apache or nginx
* PHP
* MySQL or MariaDB
This can't be a guide for how to install a webserver. Anyway if it's your first time, try with a clean Debian GNU/Linux installation, and then:
```
sudo apt install apache2 mysql-server libapache2-mod-php php-mysql
```
If you plan to allow users to upload files, install the `libmagic-dev` package.
That's all.
## Framework installation
The framework can be saved inside a read-only directory of your choice.
I usually want it in the `/usr/share/php/suckless-php` path, in order to keep it in one place for every project:
```
sudo git clone https://gitpull.it/source/suckless-php.git /usr/share/php/suckless-php
```
But instead you may want the framework inside your project or whenever you want. It will also work.
## Quick start project
We have created a template project (boilerplate) for you. You can just try it and then come back here for more instructions!
Boilerplate:
https://gitpull.it/source/suckless-php-boilerplate/
### Quick start documentation
You can configure everything, but let's assume that:
* `/var/www` is your DocumentRoot of your webserver's virtual host
* `/var/www/project` will contain your example website project (HTML, CSS, JavaScript, PHP files)
* `http://localhost/project` is what you want to visit to see that project
* `/usr/share/php/suckless-php/load.php` should exist
Spoiler! In the next steps you will create:
* `/var/www/project/load.php` - your database configuration file
* `/var/www/project/load-post.php` - your website configuration file
* `/var/www/project/index.php` - your homepage
Let's start creating a MySQL database and a MySQL user with a password.
Go to your project (`/var/www/project`) and create a configuration file. Call it `load.php` and save this:
```
<?php
// database credentials
$username = 'Your database username';
$password = 'The password of your database user';
$database = 'Your database name';
$charset = 'utf8mb4';
$location = 'localhost';
// table prefix, if any
$prefix = '';
// absolute pathname to this directory without trailing slash
define( 'ABSPATH', __DIR__ );
// relative URL to this directory (or an empty string) without trailing slash
define( 'ROOT', '/project' );
// load the framework
require '/usr/share/php/suckless-php/load.php';
```
Now create also a `load-post.php` file with something like:
```
<?php
// this file is automagically called after something called your 'load.php'
// in this file you can use every suckless-php function to describe your project
// register some custom privileges
register_permissions('subscribed', [
'add-comment',
'page-vote',
] );
// the superuser inherit from the subscribed and has also other permissions
inherit_permissions('superadmin', 'subscribed', [
'do-wonderful-things',
] );
// register some useful JavaScript/CSS files to be used later
register_js( 'jquery', 'media/jquery-min.js');
register_css( 'my-style', 'media/style.css');
// register some menu entries
// etc. (we will see features next)
```
Now create also your homepage in `index.php`:
```
<?php
// autoload everything
require 'load.php';
// test a query
$row = query_row('SELECT NOW() AS now');
// print something
echo "If the database credentials works, the time is: {$row->now}";
```
Now just visit `http://localhost/project/` to check if everything works.
## Understand what happens
Just as a note. With the above example, when you visit `http://localhost/project/` this happens:
1. The user requests `http://localhost/project`
2. `/var/www/project/index.php` is executed and then it requires your 'load.php' (see the comment `// autoload everything` above)
3. `/var/www/project/load.php` is executed and loads the framework (see the comment `/ load the framework` above)
4. `/usr/share/php/suckless-php/load.php` is executed and quits, requiring your `load-post.php`
5. `/var/www/project/load-post.php` is executed and ends
6. `/var/www/project/index.php` continue it's execution as normal
7. You see __If the database credentials works, the time is: ...__
That's all.
As you note, you decide whenever you want to use the framework or not. Just put a `require 'load.php';` as the first line of your PHP page to use the framework stuff.
## Configure database
If you want these features:
* login/logout
* user roles and permissions
* associative options
Just import the example database schema provided in the `examples` folder of this framework. Adjust the table prefixes for your needs:
* [example-schema.sql](https://gitpull.it/source/suckless-php/browse/master/examples/extras/example-schema.sql)
## All available configurations for your `load.php` file
| Config | Kind | Type | Mandatory | Description | Example |
|-------------|-------|--------|-----------|-------------------------------|------------------|
| `ABSPATH` | const | string | Mandatory | Base filesystem path | `"/var/www/foo"` |
| `ROOT` | const | string | Mandatory | Base URL path | `"/foo"` or `""` |
| `$username` | var | string | Optional | DB user | `"foo"` |
| `$password` | var | string | Optional | DB user password | `"strong"` |
| `$database` | var | string | Optional | DB name | `"FOO"` |
| `$location` | var | string | Optional | DB hostname or IP | `"localhost"` |
| `$prefix` | var | string | Optional | DB tables prefix | `"foo_"` |
| `$charset` | var | string | Optional | DB default charset | `"utf8mb4"` |
| `PROTOCOL` | const | string | Optional | Protocol in URLs | `"https://"` |
| `DOMAIN` | const | string | Optional | Domain in URLs | `"example.com"` |
| `URL` | const | string | Optional | Base URL |`"https://a.it/foo"`|
|`DEBUG_QUERIES`|const| bool | Optional | Queries are logged | `true` |
| `DEBUG` | const | bool | Optional | Print log lines to output | `true` |
Example of `const` customization:
```
define( "ABSPATH", "/var/www/foo" );
```
Example of `var` customization:
```
$database = "FOO";
$username = "foo";
$password = "strong";
$location = "localhost";
$prefix = "foo_";
```
## Troubleshooting
You should know how to inspect your webserver logs. Usually just:
```
sudo tail -f /var/log/apache2/error.log
```
In the next examples you may also want to debug your queries. Just put these inside your `/var/www/project/load.php`:
```
define( 'DEBUG_QUERIES', true );
define( 'DEBUG', true );
```
This is useful __only during development__ to show on video every database query before its execution. Remember to force to `false` in production.
## Example of JSON API
Creating a JSON API page is very simple. This is an example that can be saved as `/var/www/project/api.php`
```
<?php
// autoload everything
require 'load.php';
// do some pre-conditions
if( empty( $_GET['ping'] ) ) {
// die with an HTTP status code 400 Bad Request and show a message
json_error( 400, 'missing-ping', "Please specify the 'ping' argument in your query string" );
}
json( [
'success' => true,
'pong' => $_GET['ping'],
] );
```
Now try to visit:
* `http://localhost/project/api.php`
* `http://localhost/project/api.php?ping=foo`
Note that to optimize the data transfer, `false` and `null` values and empty objects will be automagically removed from the JSON. It's a feature. Handle these cases from JavaScript.
Note that in this example you do not use the database so no database connection was established.
## Execute a database query
Note that the database connection is established automagically only if you need it. Now some examples.
### Execute a query using the Query builder (object-oriented)
```
<?php
// autoload everything
require 'load.php';
// get a pure array of rows from an example 'post' database table
$posts = ( new Query() )
->from( 'post' )
->queryResults();
// do something with your data
foreach( $posts as $post ) {
echo $post->id;
}
```
```
<?php
// autoload everything
require 'load.php';
// get a Generator of rows from an example 'post' database table (see PHP generators)
$generator = ( new Query() )
->from( 'post' )
->queryGenerator();
// do something with your data
foreach( $posts as $post ) {
echo $post->id;
}
```
Another more complex example:
```
<?php
// autoload everything
require 'load.php';
// get a pure array of rows from an example 'post' database table
// where the 'post_author_id' = $author_id (int comparison)
// where the 'post_status' can be 'private' or 'stub' or 'deleted'
// where the 'post_date' is before the current MySQL date
// LEFT join to the user ON (user.user_ID = post.post_author_id)
$posts = ( new Query() )
->from( 'post' )
->whereStr( 'post_title', $title )
->whereInt( 'post_author_id', $author_id )
->whereSomethingIn( 'post_status', [ 'private', 'stub', 'deleted' ] )
->where( 'post_date < NOW()' )
->joinOn( 'LEFT', 'user', 'user.user_ID', 'post.post_author_id' )
->queryResults();
// do something with your data
foreach( $posts as $post ) {
echo $post->id;
}
```
Note that a method that gives a `generator` is more efficient over a method that returns a complete array of results, if:
* You do not need the full list (you need just one at time)
* You do not need to read the list multiple times.
## Insert
Object-oriented way:
```
<?php
// autoload everything
require 'load.php';
( new Query() )
->from( 'post' )
->insertRow( [
'post_ID' => 1,
'post_title' => 'hello',
] );
```
Declarative way:
```
<?php
// autoload everything
require 'load.php';
insert_row( 'post', [
'post_ID' => 1,
'post_title' => 'hello',
] );
```
## Delete
```
<?php
// autoload everything
require 'load.php';
// build a delete query and run
( new Query() )
->from( 'post' ) // DELETE FROM post
->whereStr( 'post_author', 'jhon' ) // WHERE post_author = 'jhon'
->whereInt( 'post_stub', 1 ) // AND post_stub = 1
->whereIsNull( 'post_field' ) // AND post_field IS NULL
->whereIsNotNull( 'post_z' ) // AND post_z IS NOT NULL
->delete();
```
### Query (declarative way)
This is useful to run strange queries that does not return stuff (remember to sanitize your data):
```
<?php
// autoload everything
require 'load.php';
query( "ALTER TABLE ADD COLUMN `hello` ..." );
```
### Select rows (declarative, full list)
```
<?php
// autoload everything
require 'load.php';
// results as a pure array
$posts = query_results( "SELECT post_title FROM wp_post" );
// as example, let's expose them in JSON
json( $posts );
```
Note that `query_results()` is better over `query_generator()` if:
* You need the full list (e.g. to count them, etc.)
* You want to read the list multiple times
### Select rows (declarative, generator)
```
// generator of results
$posts = query_generator( "SELECT post_title FROM wp_post" );
if( $posts->valid() ) {
foreach( $posts as $post ) {
echo $post->post_title;
}
}
```
Note that `query_generator()` is better over `query_results()` if:
* You do not need the full list (but just one at time)
* You do not need to read the list multiple times.
## Database row → object (mapping)
```
<?php
/**
* Declaration of my custom class
*/
class Post extends Queried {
/**
* Table name without table prefix
*/
const T = 'post';
/**
* An example method
*
* @return string
*/
public function getTitle() {
return $this->post_title;
}
}
// now you have a query builder
$post = Post::factory()
->whereInt( 'post_ID', 1 )
->queryRow();
// do stuff
if( $post ) {
// this method exists
$title = $post->getTitle();
}
```
## File upload
File uploading is one of the most painful stuff ever full of security nightmares. We tried to keep it simple and secure.
This is just an example:
```
<?php
// autoload everything
require 'load.php';
$uploader = new FileUploader( 'my-filename', [
'category' => [ 'image', 'video' ],
'max-filesize' =>10000000, // 10~ megabytes
] );
// check if the user uploaded the file
if( $uploader->fileChoosed() ) {
// save the file somewhere
$ok = $uploader->uploadTo( '/var/www/directory/name' );
// print an error message
if( !$ok ) {
die( $uploader->getErrorMessage() );
}
}
?>
<form method="post" enctype="multipart/form-data">
<input type="file" name="my-filename" />
<button type="submit">Submit</button>
</form>
```
Some available file categories:
* `image`
* `audio`
* `video`
* `document`
Check the class [[ https://gitpull.it/source/suckless-php/browse/master/class-MimeTypes.php | class-MimeTypes.php ]] for further details about the default categories.
You can use `register_mimetypes( $category, $mimetypes )` inside your `load-post.php` to register additional mime types to existing categories or to add additional categories.
## CSRF protection
To both identify a form and secure it against [Cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) you can use `form_action()` and `is_action()`:
```
<?php
// autoload everything
require 'load.php';
// check if the user submitted the form
if( is_action( 'save-user' ) ) {
// do something with the value
// $_POST['username']
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Example save user</title>
</head>
<body>
<form method="post">
<?php form_action( 'save-user' ) ?>
<input type="text" name="username" />
<button type="submit">Click to save</button>
</form>
</body>
</html>
```
## Example of login page
```
<?php
// autoload everything
require 'load.php';
if( is_action( 'try-login' ) ) {
login();
}
if( is_logged() ) {
echo "Welcome!";
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Example save user</title>
</head>
<body>
<form method="post">
<?php form_action( 'try-login' ) ?>
<input type="text" name="user_uid" />
<input type="password" name="user_password" />
<button type="submit">Login</button>
</form>
</body>
</html>
```
## Internationalization and localization (i28n and l10n)
The framework has support to internationalization and localization.
To add multi-language support for your web project, just convert it from this:
```
<p>Hello world!</p>
```
To this:
```
<p><?= __( "Hello world!" ) ?></p>
```
And you are almost ready.
NOTE: The function shown is called `__( "" )` with a double-underscore. Normally it just returns what you express as the first argument.
Then, in your file `load-post.php` declare your languages:
```
<?php
...
// register all the languages you want to support, with aliases and ISO code and a label
register_language( 'en_US', [ 'en_GB' ], 'en', "English" );
register_language( 'it_IT', [ ], 'it', "Italiano" );
// GNU Gettext domain
define_default( 'GETTEXT_DOMAIN', 'com.myproject' );
// apply the language from browser preferences
apply_language();
// or apply a specific language
// apply_language( "en" );
```
Then use the GNU Gettext workflow to internationalize everything. The GNU Gettext standard is the same thing used to translate WordPress.
You just have to create a structure like this in your project:
```
./l10n/com.myproject.pot
./l10n/it_IT.UTF-8/LC_MESSAGES/com.myproject.mo
./l10n/it_IT.UTF-8/LC_MESSAGES/com.myproject.po
./l10n/en_US.UTF-8/LC_MESSAGES/com.myproject.mo
./l10n/en_US.UTF-8/LC_MESSAGES/com.myproject.po
```
Then you can a script to:
1. create a `.pot` file from the strings in your source code inside the function `__( "" )`
2. use the `.pot` file as template to create or update `.po` files of each language
3. compile `.po` files into `.mo` files
Then you can use a software like [[ https://poedit.net/ | Poedit ]] to translate the `.po` files and then re-run the script to use your updated `.po` file to create `.mo` compiled files.
Here an example script that does everything for you:
```
name=localize.sh
# default copyright holder of your localization
copyright="Mr. Gino"
# prefix of your localization's files
package="com.myproject"
rtfm() {
echo Usage:
echo $1 PATH
echo Example:
echo $1 /var/www/mysite
}
path="$1"
if [ -z "$path" ]; then
rtfm $0
exit 1
fi
# Create or update the .pot template file, reading the strings inside the function __( "" ) in your PHP project
xgettext --copyright-holder="$copyright" \
--package-name=$package \
--from-code=UTF-8 \
--keyword=__ \
--default-domain="$package" \
-o "$path"/l10n/"$package".pot \
"$path"/*.php \
"$path"/*/*.php
# Generate/update the .po files from the .pot file
find "$path"/l10n -name \*.po -execdir msgmerge -o $package.po $package.po ../../$package.pot \;
# Generate/update the .mo files from .po files
find "$path"/l10n -name \*.po -execdir msgfmt -o $package.mo $package.po \;
```
So when you want to translate something just follow this workflow:
1. Run `./localize.sh`
2. Translate your `.po` files with Poedit or whatever
3. Save
3. Run `./localize.sh`
NOTE: This is nearly exactly the same workflow of WordPress but everything works without megabytes of dependencies. The GNU Gettext workflow is very powerful but somehow complicated. This is not specific to this framework. Practically every software on your computer uses this methodology. So learning GNU Gettext is not bad for you. RTFM:
* https://en.wikipedia.org/wiki/Gettext
## Unit tests
```
phpunit --bootstrap=phpunit/load.php --testdox phpunit
```
## License
Copyright (c) 2015-2023 [Valerio Bozzolan](http://boz.reyboz.it/), code contributors
This is a **Free** as in **Freedom** project. It comes with ABSOLUTELY NO WARRANTY. You are welcome to redistribute it under the terms of the **GNU General Public License v3+**.
# suckless-php: Another KISS framework that sucks less
You discovered `suckless-php`! My amazing //keep-it-simple-and-stupid// laser cannon that I used to develop dozen of very-different Content Management Systems made from scratch in my life.
## Why using suckless-php
With this framework I was able to create a lot of relational content management systems with these features:
* stateless (it means it's very easy to scale)
* efficient (very tiny footprint both in disk and memory)
* [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery)-safe
* on demand resource loader (e.g. DB connection is instantiated only if you use it etc.)
* object-oriented query builder (supporting also very complex queries)
* micro-ORM implementation
* associative options WordPress-style
* database table prefix (you can run multiple instances in a single database)
* login, user roles, user permissions, role inheritance
* secure file uploads (MIME type checks etc.)
* multi-language (using the widely used GNU Gettext native or high-level)
Everything is made in just ~15 short PHP files. It's __damn__ small for all these features!
## When not using suckless-php
* do not use `suckless-php` if you hate programming
* do not use `suckless-php` if you expect something complete with all the bells and whistles that you can give to a _luser_ to manage a website in every it's damn component without a programmer (in that case you need a shitty, scary and giant complete content management system, you need to host something like 12MB of source code, often that you must fill with other crapware plugins, etc. In this case the `suckless-php` framework is not your solution. This framework is incompatible with people who love waste of resources, computation and global warming).
## Requirements
You should have basic knowledges about a webserver with PHP and MySQL (or MariaDB obviously).
Run your favorite webserver. I use a simple (G)LAMP machine:
* GNU/Linux
* Apache or nginx
* PHP
* MySQL or MariaDB
This can't be a guide for how to install a webserver. Anyway if it's your first time, try with a clean Debian GNU/Linux installation, and then:
```
sudo apt install apache2 mysql-server libapache2-mod-php php-mysql
```
If you plan to allow users to upload files, install the `libmagic-dev` package.
That's all.
## Framework installation
The framework can be saved inside a read-only directory of your choice.
I usually want it in the `/usr/share/php/suckless-php` path, in order to keep it in one place for every project:
```
sudo git clone https://gitpull.it/source/suckless-php.git /usr/share/php/suckless-php
```
But instead you may want the framework inside your project or whenever you want. It will also work.
## Quick start project
We have created a template project (boilerplate) for you. You can just try it and then come back here for more instructions!
Boilerplate:
https://gitpull.it/source/suckless-php-boilerplate/
### Quick start documentation
You can configure everything, but let's assume that:
* `/var/www` is your DocumentRoot of your webserver's virtual host
* `/var/www/project` will contain your example website project (HTML, CSS, JavaScript, PHP files)
* `http://localhost/project` is what you want to visit to see that project
* `/usr/share/php/suckless-php/load.php` should exist
Spoiler! In the next steps you will create:
* `/var/www/project/load.php` - your database configuration file
* `/var/www/project/load-post.php` - your website configuration file
* `/var/www/project/index.php` - your homepage
Let's start creating a MySQL database and a MySQL user with a password.
Go to your project (`/var/www/project`) and create a configuration file. Call it `load.php` and save this:
```
<?php
// database credentials
$username = 'Your database username';
$password = 'The password of your database user';
$database = 'Your database name';
$charset = 'utf8mb4';
$location = 'localhost';
// table prefix, if any
$prefix = '';
// absolute pathname to this directory without trailing slash
define( 'ABSPATH', __DIR__ );
// relative URL to this directory (or an empty string) without trailing slash
define( 'ROOT', '/project' );
// load the framework
require '/usr/share/php/suckless-php/load.php';
```
Now create also a `load-post.php` file with something like:
```
<?php
// this file is automagically called after something called your 'load.php'
// in this file you can use every suckless-php function to describe your project
// register some custom privileges
register_permissions('subscribed', [
'add-comment',
'page-vote',
] );
// the superuser inherit from the subscribed and has also other permissions
inherit_permissions('superadmin', 'subscribed', [
'do-wonderful-things',
] );
// register some useful JavaScript/CSS files to be used later
register_js( 'jquery', 'media/jquery-min.js');
register_css( 'my-style', 'media/style.css');
// register some menu entries
// etc. (we will see features next)
```
Now create also your homepage in `index.php`:
```
<?php
// autoload everything
require 'load.php';
// test a query
$row = query_row('SELECT NOW() AS now');
// print something
echo "If the database credentials works, the time is: {$row->now}";
```
Now just visit `http://localhost/project/` to check if everything works.
## Understand what happens
Just as a note. With the above example, when you visit `http://localhost/project/` this happens:
1. The user requests `http://localhost/project`
2. `/var/www/project/index.php` is executed and then it requires your 'load.php' (see the comment `// autoload everything` above)
3. `/var/www/project/load.php` is executed and loads the framework (see the comment `/ load the framework` above)
4. `/usr/share/php/suckless-php/load.php` is executed and quits, requiring your `load-post.php`
5. `/var/www/project/load-post.php` is executed and ends
6. `/var/www/project/index.php` continue it's execution as normal
7. You see __If the database credentials works, the time is: ...__
That's all.
As you note, you decide whenever you want to use the framework or not. Just put a `require 'load.php';` as the first line of your PHP page to use the framework stuff.
## Configure database
If you want these features:
* login/logout
* user roles and permissions
* associative options
Just import the example database schema provided in the `examples` folder of this framework. Adjust the table prefixes for your needs:
* [example-schema.sql](https://gitpull.it/source/suckless-php/browse/master/examples/extras/example-schema.sql)
## All available configurations for your `load.php` file
| Config | TypeKind | Data typeType | Mandatory | Description | Example | |
|-------------|-------|--------|-----------|------------------|-------------|--------|----------|
| `ABSPATH` | const | string | Mandatory | F| Base filesystem path. No end slash. | `"/var/www/foo"` |
| `ROOT` | const | string | Mandatory || Base URL path. No end slash. | `"/foo"` |or `""` |
| `$username` | var | string | Optional | DB user. | `"foo"` |
| `$password` | var | string | Optional | DB user password. | `"strong"` |
| `$database` | var | string | Optional | DB name. | `"FOO"` |
| `$location` | var | string | Optional | DB hostname or IP. | `"localhost"` |
| `$prefix` | var | string | Optional | DB tables prefix. | `"foo_"` |
| `$charset` | var | string | Optional | DB default charset. | `"utf8mb4"` |
| `PROTOCOL` | const | string | Optional | Preferred web protocol for URLs.otocol in URLs | `"https://` "` |
| `DOMAIN` | const | string | Optional | Preferred d | Domain forin URLs. | `"example.com` |"` |
| `URL` | const | string | Optional | Preferred base URL. | Base URL | ``"https://foo.coma.it/foo` |"`|
| |`DEBUG_QUERIES` | |const | bool | Optional | Queries are logged. | `true` |
| `DEBUG` | const | bool | Optional | Print log lines to output. | `true` |
Example of `const` customization:
```
define( "ABSPATH", "/var/www/foo" );
```
Example of `var` customization:
```
$database = "FOO";
$username = "foo";
$password = "strong";
$location = "localhost";
$prefix = "foo_";
```
## Troubleshooting
You should know how to inspect your webserver logs. Usually just:
```
sudo tail -f /var/log/apache2/error.log
```
In the next examples you may also want to debug your queries. Just put these inside your `/var/www/project/load.php`:
```
define( 'DEBUG_QUERIES', true );
define( 'DEBUG', true );
```
This is useful __only during development__ to show on video every database query before its execution. Remember to force to `false` in production.
## Example of JSON API
Creating a JSON API page is very simple. This is an example that can be saved as `/var/www/project/api.php`
```
<?php
// autoload everything
require 'load.php';
// do some pre-conditions
if( empty( $_GET['ping'] ) ) {
// die with an HTTP status code 400 Bad Request and show a message
json_error( 400, 'missing-ping', "Please specify the 'ping' argument in your query string" );
}
json( [
'success' => true,
'pong' => $_GET['ping'],
] );
```
Now try to visit:
* `http://localhost/project/api.php`
* `http://localhost/project/api.php?ping=foo`
Note that to optimize the data transfer, `false` and `null` values and empty objects will be automagically removed from the JSON. It's a feature. Handle these cases from JavaScript.
Note that in this example you do not use the database so no database connection was established.
## Execute a database query
Note that the database connection is established automagically only if you need it. Now some examples.
### Execute a query using the Query builder (object-oriented)
```
<?php
// autoload everything
require 'load.php';
// get a pure array of rows from an example 'post' database table
$posts = ( new Query() )
->from( 'post' )
->queryResults();
// do something with your data
foreach( $posts as $post ) {
echo $post->id;
}
```
```
<?php
// autoload everything
require 'load.php';
// get a Generator of rows from an example 'post' database table (see PHP generators)
$generator = ( new Query() )
->from( 'post' )
->queryGenerator();
// do something with your data
foreach( $posts as $post ) {
echo $post->id;
}
```
Another more complex example:
```
<?php
// autoload everything
require 'load.php';
// get a pure array of rows from an example 'post' database table
// where the 'post_author_id' = $author_id (int comparison)
// where the 'post_status' can be 'private' or 'stub' or 'deleted'
// where the 'post_date' is before the current MySQL date
// LEFT join to the user ON (user.user_ID = post.post_author_id)
$posts = ( new Query() )
->from( 'post' )
->whereStr( 'post_title', $title )
->whereInt( 'post_author_id', $author_id )
->whereSomethingIn( 'post_status', [ 'private', 'stub', 'deleted' ] )
->where( 'post_date < NOW()' )
->joinOn( 'LEFT', 'user', 'user.user_ID', 'post.post_author_id' )
->queryResults();
// do something with your data
foreach( $posts as $post ) {
echo $post->id;
}
```
Note that a method that gives a `generator` is more efficient over a method that returns a complete array of results, if:
* You do not need the full list (you need just one at time)
* You do not need to read the list multiple times.
## Insert
Object-oriented way:
```
<?php
// autoload everything
require 'load.php';
( new Query() )
->from( 'post' )
->insertRow( [
'post_ID' => 1,
'post_title' => 'hello',
] );
```
Declarative way:
```
<?php
// autoload everything
require 'load.php';
insert_row( 'post', [
'post_ID' => 1,
'post_title' => 'hello',
] );
```
## Delete
```
<?php
// autoload everything
require 'load.php';
// build a delete query and run
( new Query() )
->from( 'post' ) // DELETE FROM post
->whereStr( 'post_author', 'jhon' ) // WHERE post_author = 'jhon'
->whereInt( 'post_stub', 1 ) // AND post_stub = 1
->whereIsNull( 'post_field' ) // AND post_field IS NULL
->whereIsNotNull( 'post_z' ) // AND post_z IS NOT NULL
->delete();
```
### Query (declarative way)
This is useful to run strange queries that does not return stuff (remember to sanitize your data):
```
<?php
// autoload everything
require 'load.php';
query( "ALTER TABLE ADD COLUMN `hello` ..." );
```
### Select rows (declarative, full list)
```
<?php
// autoload everything
require 'load.php';
// results as a pure array
$posts = query_results( "SELECT post_title FROM wp_post" );
// as example, let's expose them in JSON
json( $posts );
```
Note that `query_results()` is better over `query_generator()` if:
* You need the full list (e.g. to count them, etc.)
* You want to read the list multiple times
### Select rows (declarative, generator)
```
// generator of results
$posts = query_generator( "SELECT post_title FROM wp_post" );
if( $posts->valid() ) {
foreach( $posts as $post ) {
echo $post->post_title;
}
}
```
Note that `query_generator()` is better over `query_results()` if:
* You do not need the full list (but just one at time)
* You do not need to read the list multiple times.
## Database row → object (mapping)
```
<?php
/**
* Declaration of my custom class
*/
class Post extends Queried {
/**
* Table name without table prefix
*/
const T = 'post';
/**
* An example method
*
* @return string
*/
public function getTitle() {
return $this->post_title;
}
}
// now you have a query builder
$post = Post::factory()
->whereInt( 'post_ID', 1 )
->queryRow();
// do stuff
if( $post ) {
// this method exists
$title = $post->getTitle();
}
```
## File upload
File uploading is one of the most painful stuff ever full of security nightmares. We tried to keep it simple and secure.
This is just an example:
```
<?php
// autoload everything
require 'load.php';
$uploader = new FileUploader( 'my-filename', [
'category' => [ 'image', 'video' ],
'max-filesize' =>10000000, // 10~ megabytes
] );
// check if the user uploaded the file
if( $uploader->fileChoosed() ) {
// save the file somewhere
$ok = $uploader->uploadTo( '/var/www/directory/name' );
// print an error message
if( !$ok ) {
die( $uploader->getErrorMessage() );
}
}
?>
<form method="post" enctype="multipart/form-data">
<input type="file" name="my-filename" />
<button type="submit">Submit</button>
</form>
```
Some available file categories:
* `image`
* `audio`
* `video`
* `document`
Check the class [[ https://gitpull.it/source/suckless-php/browse/master/class-MimeTypes.php | class-MimeTypes.php ]] for further details about the default categories.
You can use `register_mimetypes( $category, $mimetypes )` inside your `load-post.php` to register additional mime types to existing categories or to add additional categories.
## CSRF protection
To both identify a form and secure it against [Cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) you can use `form_action()` and `is_action()`:
```
<?php
// autoload everything
require 'load.php';
// check if the user submitted the form
if( is_action( 'save-user' ) ) {
// do something with the value
// $_POST['username']
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Example save user</title>
</head>
<body>
<form method="post">
<?php form_action( 'save-user' ) ?>
<input type="text" name="username" />
<button type="submit">Click to save</button>
</form>
</body>
</html>
```
## Example of login page
```
<?php
// autoload everything
require 'load.php';
if( is_action( 'try-login' ) ) {
login();
}
if( is_logged() ) {
echo "Welcome!";
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Example save user</title>
</head>
<body>
<form method="post">
<?php form_action( 'try-login' ) ?>
<input type="text" name="user_uid" />
<input type="password" name="user_password" />
<button type="submit">Login</button>
</form>
</body>
</html>
```
## Internationalization and localization (i28n and l10n)
The framework has support to internationalization and localization.
To add multi-language support for your web project, just convert it from this:
```
<p>Hello world!</p>
```
To this:
```
<p><?= __( "Hello world!" ) ?></p>
```
And you are almost ready.
NOTE: The function shown is called `__( "" )` with a double-underscore. Normally it just returns what you express as the first argument.
Then, in your file `load-post.php` declare your languages:
```
<?php
...
// register all the languages you want to support, with aliases and ISO code and a label
register_language( 'en_US', [ 'en_GB' ], 'en', "English" );
register_language( 'it_IT', [ ], 'it', "Italiano" );
// GNU Gettext domain
define_default( 'GETTEXT_DOMAIN', 'com.myproject' );
// apply the language from browser preferences
apply_language();
// or apply a specific language
// apply_language( "en" );
```
Then use the GNU Gettext workflow to internationalize everything. The GNU Gettext standard is the same thing used to translate WordPress.
You just have to create a structure like this in your project:
```
./l10n/com.myproject.pot
./l10n/it_IT.UTF-8/LC_MESSAGES/com.myproject.mo
./l10n/it_IT.UTF-8/LC_MESSAGES/com.myproject.po
./l10n/en_US.UTF-8/LC_MESSAGES/com.myproject.mo
./l10n/en_US.UTF-8/LC_MESSAGES/com.myproject.po
```
Then you can a script to:
1. create a `.pot` file from the strings in your source code inside the function `__( "" )`
2. use the `.pot` file as template to create or update `.po` files of each language
3. compile `.po` files into `.mo` files
Then you can use a software like [[ https://poedit.net/ | Poedit ]] to translate the `.po` files and then re-run the script to use your updated `.po` file to create `.mo` compiled files.
Here an example script that does everything for you:
```
name=localize.sh
# default copyright holder of your localization
copyright="Mr. Gino"
# prefix of your localization's files
package="com.myproject"
rtfm() {
echo Usage:
echo $1 PATH
echo Example:
echo $1 /var/www/mysite
}
path="$1"
if [ -z "$path" ]; then
rtfm $0
exit 1
fi
# Create or update the .pot template file, reading the strings inside the function __( "" ) in your PHP project
xgettext --copyright-holder="$copyright" \
--package-name=$package \
--from-code=UTF-8 \
--keyword=__ \
--default-domain="$package" \
-o "$path"/l10n/"$package".pot \
"$path"/*.php \
"$path"/*/*.php
# Generate/update the .po files from the .pot file
find "$path"/l10n -name \*.po -execdir msgmerge -o $package.po $package.po ../../$package.pot \;
# Generate/update the .mo files from .po files
find "$path"/l10n -name \*.po -execdir msgfmt -o $package.mo $package.po \;
```
So when you want to translate something just follow this workflow:
1. Run `./localize.sh`
2. Translate your `.po` files with Poedit or whatever
3. Save
3. Run `./localize.sh`
NOTE: This is nearly exactly the same workflow of WordPress but everything works without megabytes of dependencies. The GNU Gettext workflow is very powerful but somehow complicated. This is not specific to this framework. Practically every software on your computer uses this methodology. So learning GNU Gettext is not bad for you. RTFM:
* https://en.wikipedia.org/wiki/Gettext
## Unit tests
```
phpunit --bootstrap=phpunit/load.php --testdox phpunit
```
## License
Copyright (c) 2015-20223 [Valerio Bozzolan](http://boz.reyboz.it/), code contributors
This is a **Free** as in **Freedom** project. It comes with ABSOLUTELY NO WARRANTY. You are welcome to redistribute it under the terms of the **GNU General Public License v3+**.
Public contents are in Creative Commons Attribution-ShareAlike 4.0 (CC-BY-SA) or GNU Free Documentation License (at your option) unless otherwise noted. · Contact / Register