Khor Shuqi

Building a PHP Site Without a Framework

Nowadays when you want to build a website, the first thing that comes to mind is to choose a framework.

Assumed that you have already chosen AMP as your stack of choice (Apache + MySQL + PHP), the most popular frameworks out there are Laravel, CodeIgniter, Slim and probably WordPress (if you consider it as a framework at all).

But in fact, you don’t always need a framework. What you need may be just a couple of tools (in the form of library) and some reusable codes.

Advantages of a Framework and How to Make Up for Them

A typical framework provides ready-to-use setup and tools in these aspects:

  1. Routing
  2. Separation of MVC
  3. Database Model
  4. Templating
  5. Language Internationalisation
  6. Error Handling

Let’s explore each of these aspects and how could we make up for it in the most minimal way.

1. Routing

In my typical setup, the first thing that I would do is to create a .htaccess file at root directory to rewrite the URL:RewriteEngine on

Apache
RewriteEngine on

RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f

RewriteRule ^(.*)$ index.php?path=$1 [B,QSA]

These lines would encode the whole path to $_GET[‘path’] and forward it to index.php.

Then in your index.php, process the path and include relevant php files:

PHP
<?php

$path = array();
$page = 'home';
if (!empty($_GET['path'])) {
  $path = explode('/', urldecode($_GET['path']));
  $page = array_shift($path);
}

switch ($page) {
  case 'home':
    require('home.php');
    break;
  case 'product':
    require('product.php');
    break;
  case 'contact':
    require('contact.php');
    break;
  default:
    // page not found
}

In this setup, $page will determine where to route, and the subsequent parts in $_GET[‘path’] will be mapped to $path array.

For example, /product/edit/69 will give you:

PHP
$page = 'product';
$path = ['edit', '69'];

…where you could determine whether to list, add or edit by referring to $path[0] and find the product ID in $path[1].

2. Separation of MVC

MVC stands for Model, View and Control (of course).

What I typically do, in a procedural way, is to do all the Control at the top of the PHP file, and do all the View at the bottom.

PHP
<?php

/*
  controls go here
  ...
  ...
*/

ob_start();
?>

<!-- prepare output -->

<?php
$output_content = ob_get_clean();
$output_title = "Page Title";
require('template.php');
exit;

ob_start() and ob_get_clean() will collect all the output buffer, and in this case store in $output_content, where you could use it later in template.php. Be cautious here as there’s a limit on how large the output buffer can be, but for the past 10 years or so I haven’t experienced any problem.

Of course, this doesn’t really fulfil separation of concerns as you need to pass a bunch of variables around different PHP files.

Write an Output Class

That’s why I wrote a singleton Output class to handle it. My Output class structure looks somewhat like this:

PHP
<?php

class Output {
  private static $instance;
  private string $title = 'Welcome!';
  private array $script_top = array();
  private array $script_bottom = array();
  private array $css = array();
  private string $template = 'template/default.php';

  public static function get_instance ():Output {}
  private function __construct () {}

  public function set_title (string $title) {}
  public function add_script (string $url, boolean $page_bottom = false) {}
  public function add_css (string $url) {}
  public function use_template (string $path) {}
  public function print (string $content, array $vars = null) {}
}

This is a very straightforward class and it’s pretty self-explanatory. You could reuse the class in every other project.

You still need to pass variables to your template file in print() though, but at least it seems much more manageable:

PHP
<?php

$output = Output::get_instance();
$output->set_title('Page Title');
$output->print(ob_get_clean());

3. Database Model

Frameworks usually come with some kind of ORM. You configure your data model in the framework, and it will manage the database for you automatically.

With its database abstraction, most of the times you don’t even need to remember SQL syntax, as it could generate the most complicated SQL queries that you could never possibly write by yourself, as long as you follow the ORM’s syntax which often works by chaining a bunch of class method calls.

But if you’re comfortable with MySQL (won’t jump to any NoSQL database soon), and feel more in control by executing CRUD queries yourself, all you need is the built in PDO (and maybe a database wrapper).

Using PDO

With PDO, data are sent separately so you don’t need to escape the data by yourself:

PHP
<?php

// create connection
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass);
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->query("SET character_set_results=utf8mb4");
$pdo->query("SET character_set_client=utf8mb4");
$pdo->query("SET character_set_connection=utf8mb4");

// execute query
$query = $pdo->prepare("SELECT * FROM `users` WHERE `email` = ?");
$query->execute($_POST['email'] ?? '');
$user = $query->fetch(PDO::FETCH_ASSOC);

Of course in best practice, you should still sanitise and validate user input first before querying the database, this is just an example to show how safe it could be.

Get a Database Wrapper

I would recommend to get a database wrapper to ease things up for you, though. I’m using one that I modified from an old script, but it’s quite similar to this easydb.

An example from easydb GitHub:

PHP
$db->insert('comments', [
    'blogpostid' => $_POST['blogpost'],
    'userid' => $_SESSION['user'],
    'comment' => $_POST['body'],
    'parent' => $_POST['replyTo'] ?? null
]);

Note that you still need to write SQL queries from time to time, but it makes it much easier for you to update and insert data to the database.

Objectify Your Core Data

Back to ORM, the way it usually works is to ‘objectify’ all your data (eg. a class for each table). This is of course the best practice in OOP.

But more often than not, you only need to objectify the most important data in your program, for example: user, product, cart and post.

An example of a user class may look like this:

PHP
class User {
  public function __construct () {}

  // log in and out
  public function login (string $username, string $password):bool {}
  public function logout ():bool {}
  private function encrypt (string $password):string {}

  // register
  public function create ():bool {}

  // get error message
  public function get_error ():string {}

  // getters
  public function is_logged_in ():bool {}
  public function get_id ():int {}
  public function get_username ():string {}
  public function get_display_name ():string {}
  public function get_purchase_list ():array {}
  public function get_point ():int {}

  // setters
  public function update (array $data):bool {}
  public function add_point (int $point):bool {}
}

There is no ‘delete’ here because this is meant for the client site.

In our case, since it’s too tedious to create a class for each table, instead of making your model represent how your database looks like, your model should reflect on how your website works.

4. Templating

There are many templating engines out there where you could use literals like {{$var}} for your codes to look tidier.

But PHP was originally designed to be a templating engine anyway. For sure the syntax isn’t as nice, but once you get used to it, it isn’t that bad. WordPress is using it too.

One change I would recommend is to use if and endifforeach and endforeach in place of brackets when you’re preparing a template:

PHP
<?php if ($foreigner): ?>
  <select name="countries">
    <?php foreach ($countries as $country): ?>
      <option id="<?= $country['id'] ?>"><?= $country['name'] ?></option>
    <?php endforeach ?>
  </select>
<?php endif ?>

See? You don’t even need a semicolon to end the line!

By utilising endifendfor and endforeach and proper indentation, you won’t be scrambling to find the start and the end of a bracket.

Multiple Template Files

Assume that you have a directory full of template files, you could write a simple function to insert variables for you:

PHP
<?php

/**
 * Load a template file and extract $vars for use in the template.
 * 
 * @param string     $file_name  The file name of the template without extension.
 * @param string     $content    The content to be inserted to the template.
 * @param array|null $vars       Associative array to be extracted.
 * 
 * @return string    The output from template.
 */
function use_template (string $file_name, string $content, $vars = null):string {
  $template_path = __DIR__ . "/template/$file_name.php";
  if (!is_file($template_path)) {
    trigger_error("Template doesn\'t exist.");
    return $content;
  }

  if (!empty($vars)) extract($vars);

  ob_start();
  include($template_path);
  return ob_get_clean() ?: $content;
}

5. Language Internationalisation

In most frameworks, you could enclose your strings in a certain ways (eg. _(“String to be translated”); ) to prepare for output in other languages (aka. i18n).

If your website doesn’t need multiple language support, you could skip this part entirely.

But if you do, the default tool to go for is Gettext. You should check your phpinfo() and see if it’s available. Gettext could be installed via apt or yum on Linux and brew on MacOS.

The caveat is that it doesn’t work on some Windows setups, especially when you use an AMP suite like XAMPP or AMPPS. As far as our tests go, the most reliable way to get Gettext to work on Windows is via Linux Subsystem (ie. WSL), which I will cover in the future.

Implementing Gettext

Gettext has certain folder structure you need to adhere, and you need to compile your translation file (.po file) to binary (.mo file). Go follow some tutorials.

In a nutshell, you need to prepare a .po file at {site-root}/language/{locale-code}/LC-MESSAGES/. This can be generated automatically by apps like Poedit.

In our online store project, we have a Chinese translation at /language/zh_CN/LC-MESSAGES/onlinestore.po, and we bound it like this in index.php:

PHP
<?php

$user = User::init();
if (isset($_GET['lang'])) $user->set_lang($_GET['lang']);
if ($user->get_lang() == 'zh') {
  putenv("LANG=zh_CN");
  setlocale(LC_ALL, 'zh_CN.UTF-8', 'zh_CN', 'zh', 'chs');
}
bindtextdomain("onlinestore", __DIR__."/language");
textdomain("onlinestore");

Download Poedit, Really!

I highly recommend you to download this free app called Poedit, which will save you a looooooot of time by providing an easy-to-use GUI, scanning your project for new texts and compiling to .mo file automatically.

Once you get it to work, you could use the same setup on every other project.

English as the Default Language

Another recommendation is to write your entire website in English, and use Poedit to translate to your local language.

While it is possible to do otherwise, it is quite a pain to get it to work properly. In our case, our online store was originally developed in Chinese, so naturally we tried to use it as the default language, like:

PHP
<?= printf( _("积分兑换(%d分)"), $point_redeemed ) ?>

I don’t quite remember what were the problems we faced, but I do remember that our solution in the end was to rewrite every string so that the English becomes the default version. Since then our lives have been quite peaceful.

6. Error Handling

I’m not particularly good in error handling, but I’ll write about my attempts anyway.

Suppressing Error Display in Production

In my usual setup, I have a boolean constant called ‘DEV_MODE’ in my config file. With this constant, I will determine whether to show or hide errors, and even to use sandbox profile or not while using some APIs:

PHP
<?php

ini_set('log_errors', 1);
ini_set('display_errors', DEV_MODE ? 1 : 0);
ini_set('display_startup_errors', DEV_MODE ? 1 : 0);
error_reporting(E_ALL);

Displaying Error Page

The simplest way to display an error page is to write a function for it. You could couple it with the use_template() function above:

PHP
<?php

function oops ($title, $message) {
  // clear everything already in output buffer
  if (ob_get_length()) ob_clean();

  echo use_template('error', $message, ['title' => $title]);
  exit;
}

Note that ob_clean() could only remove output buffer that hasn’t been ‘flushed’ out. So better make it a habit to only output at the very end of your operation. This is also one of the reasons why MVC separation is a good practice.

Debugging

If for any reason you don’t use a debugging tool, you could write a function to trace a specific variable.

We have one called lol() that we use occasionally. (I mean, we never actually laughed out loud when debugging, it’s a passive aggressive expression me and my colleagues use all the time lol)

PHP
<?php

/**
  * For debugging purpose. Trace a variable and stop everything.
  * Only runs in DEV_MODE.
  * 
  * @param $data  The variable to trace.
  * @param $dump  Whether to use var_dump or print_r.
  */
function lol ($data, $dump = false) {
  if (!DEV_MODE) return;

  if (ob_get_length()) ob_clean();
  if (!headers_sent()) header('Content-Type: text/plain');

  if ($dump) {
    var_dump($data);
  } else {
    print_r($data);
  }
  exit;
}

Now that we have covered all 6 aspects we mentioned above.

I’ll write about some other practices that I think is quite standard.

Start Up Initiation

These are the lines that we include at the start of every project:

PHP
<?php

mb_internal_encoding('UTF-8');
date_default_timezone_set('Asia/Kuala_Lumpur');
session_start();

Even if your country has multiple timezones, I think it’s best to set one that’s based on your dev team location.

Helpful Constants

In our projects, we usually have a few constants set up for easy reference. DEV_MODE is one of them, the others being SITE_URLSITE_ROOTADMIN_ROOT and MAINTENANCE_MODE.

SITE_URL

This is mainly for use when generating URL for links. We always couple it with a function called make_url():

PHP
<?php

define('SITE_URL', "https://example.com");

/**
  * Combine SITE_URL and $path, and append query string.
  *
  * @param string     $path    The path after SITE_URL.
  * @param array|null $params  The query string to be appended, in associative array.
  */
function make_url (string $path = '', $params = null):string {
  $url = SITE_URL . $path;

  if (!empty($params)) {
    array_walk($params, function (&$value, $key) {
      $value = "$key=$value";
    });

    $url .= (strpos($path, '?') !== false) ? '&' : '?';
    $url .= implode("&", $params);
  }

  return $url;
}

make_url("/category/book", ['page' => 2]);
// returns https://example.com/category/book?page=2

This function is similar to the site_url() function in WordPress. You could call it url() if you like, which is even shorter.

SITE_ROOT and ADMIN_ROOT

On top of that, we would have SITE_ROOT and ADMIN_ROOT defined in index.php, for example:

PHP
<?php

define("SITE_ROOT", __DIR__);
define("ADMIN_ROOT", SITE_ROOT.'/admin');

This is so that when we want to include some other PHP files, instead of calling __DIR__ which may be different from file to file (eg. from the template folder), we know exactly where to go from SITE_ROOT.

MAINTENANCE_MODE

This is for when we need to close the website quickly for maintenance. Just make a page (eg. close.php) that display some sorry messages, and add these lines in index.php:

PHP
<?php

define('MAINTENANCE_MODE', true); // close for maintenance

if (MAINTENANCE_MODE && !isset($_GET['test']) && !isset($_SESSION['ignore_close'])) {
  require('closed.php');
} elseif (isset($_GET['test'])) {
  $_SESSION['ignore_close'] = true;
}

When you’re closing for maintenance, just set the constant to true.

The codes above will also provide you a backdoor so that you could still access the website while it’s out of reach from your users. To access the backdoor, just append ?test=1 to the end of your website URL. You don’t need to append anymore after that until the session expires.

Wrapping Up

In fact, there’s much more a framework could do than our setup. For example, even if WordPress (as a framework) isn’t as object-oriented as other frameworks, it provides filter and action hooks, which makes it easy to implement functional programming.

But if you really want to setup a site without a framework, these are the stuff that I have mentioned above in the sequence of what I usually have in my projects:

  1. Start up initialisation
  2. Define SITE_ROOT and ADMIN_ROOT
  3. Include config files
  4. Configure error reporting level based on DEV_MODE
  5. Include dependencies like database wrapper and composer autoload
  6. Include global functions (I put all these functions in a single PHP file)
  7. Init your own autoload using spl_autoload_register() for custom namespace
  8. Check if need to close for maintenance
  9. Read from php://input and replace $_POST if a request in JSON format is detected (To prepare for ES6 Fetch API)
  10. Initiate global variables / classes (eg. $db, $user)
  11. Start routing
  12. Page specific controls
  13. Output

All of these, including your file structure, are very easy to clone from one project to another. Mine is more of a procedural style but you could easily wrap it in classes to be object-oriented.

I’m not saying that this is the best way to setup a lightweight project, but it has been quite reliable for me in the past years (which I kept improving) and has served my purposes well.

If you are a framework user, then by all means use a framework. Not only does the speed of development vastly improve using a framework (albeit the learning curve), the security is handled better too.

Anyway, hope you have fun!