Lesson 1 - Database abstraction
Lesson 2.5 - Cleanup
Lesson 3 - Exceptions and logging
With our database issues at least somewhat addressed, let's move on to routing. Getting proper routing set up will help decouple our URIs from our PHP scripts, will allow greater flexibility in changing our routes, and will help set us on the path of separation of concerns. The purist in me wants to reach for the Symfony components -- and I strongly encourage you to give those a look -- but for the sake of simplicity, let's take a look at Klein. I have used this router in a number of small projects and love its small footprint and lack of dependencies.
Step 1, of course, is to have Composer pull down this new dependency. At the time of writing, the current stable version is 2.1.0, so we'll update our composer.json to look like this
Code: Select all
{
"require": {
"vlucas/phpdotenv": "~2.0",
"klein/klein": "~2.1"
}
}
composer update again and we're ready to start using Klein.There are a few things we're going to be doing here, and we'll be doing them all at once because they're all interrelated. In order to properly make use of our new router, we're going to move away from page based scripts (ie. foo.php) and start using what's known as the Front Controller Pattern. At its simplest, that's a fancy way of saying we send all requests to
index.php, and let the code in there sort things out. Why is this desirable? For one, having all of our requests flow through a given location means we can handle a lot of application bootstrapping in a single location. This means not having to call session_start or include our dbconn.php in every single file. This means looking at just our route definitions will give us a good high level idea of what's going on across the entirety of the application. This also means we're going to be doing a little restructuring. Rather than having individual page scripts each pull in header, footer, etc, we'll move to a single layout we'll load and into which we'll inject the view specific to the request we're currently responding to. It's really a very similar idea to what we're seeing in the application now, just approached from a different perspective. Also, with every request going through our front controller, we can move the other views out of the document root and into our app/ directory.Let's start by creating a new directory to hold our layout and our individual views. We could create this as a sibling of app or as a subdirectory within it. Doesn't really matter. As Squeaker is a fairly small application and isn't going to be a ton of files, let's just create it inside
app/. We'll create a templates directory inside app, and then a layouts and a views directory inside that. Our directory structure, then, will look like this[text]app/
templates/
layouts/
views/
public/
vendor/[/text]
Now if we're going to be using
index.php as our starting point, we're going to need move the contents of our existing index.php some place else. So here's what we'll do. Since most of the markup in index.php is the same as what's in header.php and footer.php, let's use it as the base for our main layout. Let's copy the entirety of public/index.php to app/templates/layouts/main.php. We can now get rid of everything in public/index.php and get started defining some routes. Before we can create a new Klein object, we need to call Composer's autoloader so our application knows where to find it. But didn't we already do that in dbconn.php? And isn't repetition bad? We did, and it is. To keep things simple for now, let's copy the code from dbconn.php into our public/index.php and just get rid of dbconn.php altogether. We won't be needing that anymore. Our public/index.php should now look like thisCode: Select all
<?php
error_reporting(E_ALL);
require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/app/DB.php';
$config = new Dotenv\Dotenv(dirname(__DIR__));
$config->load();
$pdo = new PDO('mysql:host=' . getenv('DBHOST') . ';dbname=' . getenv('DBNAME'), getenv('DBUSER'), getenv('DBPASS'));
$db = new Squeaker\DB($pdo);Code: Select all
$router = new Klein\Klein;
$router->get('/', function() {
echo "Hello, World";
});
$router->dispatch();
Code: Select all
$router = new Klein\Klein;Code: Select all
$router->get('/', function() {
echo "Hello, World";
});get method and pass it first the URL we want it to match -- / for the root of the app -- and then a callback to execute when that route is matched. Code: Select all
$router->dispatch();Code: Select all
$router->get('/another', function() {
echo "This is another page.";
});dispatch call, point our browser to /another and... oh. That was unexpected. We stated that we wanted all our requests to go through the front controller but never took steps to make sure that happened. Let's create a simple .htaccess redirect in our document root to make sure all requests actually go to index.php[text]RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule . /index.php [L]
[/text]
Now if we reload we get the expected behaviour. Great, but these aren't the actual pages we want to be loading. Let's start with the home page. The callback we're passing to
get actually accepts four parameters, request, response, service, and app. We can then tell the service to render a view we specify.Code: Select all
$router->get('/', function($request, $response, $service, $app) {
$service->render(dirname(__DIR__) . '/app/templates/layouts/main.php');
});dbconn requirement from the layout as we have deleted that file)Loading the main page in the browser again, we're back to seeing what we're used to. We're making progress. Still, the layouts directory is meant to contain layouts that affect all pages and the individual page elements are meant to be in the views directory. In order to achieve this, we need to identify which parts of the layout are common to all pages and extract the remaining portion into what will be its view. Comparing this layout against our
public/header.php, it's clear that everything up to the opening div.body tag is part of the layout, and we'll want to close all of the tags we've opened, leaving just the jumbotron as our landing page view. Let's go ahead and cut that bit out and paste it into a new file; app/templates/views/index.php. We replace the code we just removed with a call to yieldView so the correct view gets injected, and all that's left is making a few changes to our route definition.Our route definition now looks like this
Code: Select all
$router->get('/', function($request, $response, $service, $app) {
$service->layout(dirname(__DIR__) . '/app/templates/layouts/main.php');
$service->render(dirname(__DIR__) . '/app/templates/views/index.php');
});Code: Select all
<?php
session_start();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Squeaker</title>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<nav class="navbar navbar-default navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<?php if (isset($_SESSION['username'])): ?>
<a class="navbar-brand" href="home.php">Squeaker</a>
<?php else: ?>
<a class="navbar-brand" href="/">Squeaker</a>
<?php endif; ?>
</div>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<?php if (isset($_SESSION['username'])): ?>
<li><a href="me.php">Me</a></li>
<li><a href="profile.php">Profile</a></li>
<?php endif; ?>
<li><a href="browse.php">Browse</a></li>
</ul>
<ul class="nav navbar-nav pull-right">
<?php if (isset($_SESSION['username'])): ?>
<li><a href="logout.php">Logout</a></li>
<?php else: ?>
<li><a href="login.php">Login</a></li>
<?php endif; ?>
</ul>
</div>
</div>
</nav>
<div class="container body">
<div class="row">
<?= $this->yieldView(); ?>
</div>
</div>
<script src="//code.jquery.com/jquery-1.11.3.min.js"></script>
<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
</body>
</html>Code: Select all
<div class="jumbotron">
<h1>Welcome to Squeaker!</h1>
<p>This totally isn't a badly done Twitter clone, I promise. It has cupcakes and stuff.</p>
<div class="clearfix">
<a href="register.php" class="btn btn-lg btn-primary pull-right">Register</a>
</div>
</div>public/browse.php to app/templates/views/browse.php and set up our route like soCode: Select all
$router->get('/browse', function($request, $response, $service, $app) {
$service->layout(dirname(__DIR__) . '/app/templates/layouts/main.php');
$service->render(dirname(__DIR__) . '/app/templates/views/browse.php');
});session_start as well. With that all trimmed out, our view looks something like thisCode: Select all
<?php
if (isset($_SESSION['username'])) {
$user_id = $db->getUserID($_SESSION['username']);
$following = $db->getUsersBeingFollowed($user_id);
$users = $db->getUsersExcept($user_id);
} else {
$following = array();
$users = $db->getAllUsers();
}
?>
<?php if (!empty($users)): ?>
<ul class="users">
<?php foreach ($users as $user): ?>
<li>
<article>
<h4>
<a href="user.php?user=<?= $user->id; ?>">
<?= !empty($user->display_name) ? $user->display_name : $user->username; ?>
</a>
</h4>
<div class="following pull-right">
<?php if (in_array($user->id, $following)): ?>
<a href="unfollow.php?user=<?= $user->id; ?>" class="btn btn-danger">Unfollow</a>
<?php else: ?>
<a href="follow.php?user=<?= $user->id; ?>" class="btn btn-info">Follow</a>
<?php endif; ?>
</div>
</article>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
browse in our browser... we get errors. $db not defined? Hmm. We clearly defined it in our bootstrapping code. Two things to consider here: 1. It's not defined within the scope of our route callback, and 2. DB calls don't really belong in the view anyway. What we want to do is make the DB object available to our routes, perform whatever calls we need to within our route callbacks, and then pass the relevant results to the view for display. This latter is an important point when we're talking about the separation of concerns. Every part of the application has a responsibility. Views are responsible for displaying our markup and nothing else. They should be devoid of all but the simplest logic; simple conditionals and iterating over results sets. With that in mind, let's move that logic out of the view and into our route definition.Code: Select all
$router->get('/browse', function($request, $response, $service, $app) use ($db) {
if (isset($_SESSION['username'])) {
$user_id = $db->getUserID($_SESSION['username']);
$following = $db->getUsersBeingFollowed($user_id);
$users = $db->getUsersExcept($user_id);
} else {
$following = array();
$users = $db->getAllUsers();
}
$service->layout(dirname(__DIR__) . '/app/templates/layouts/main.php');
$service->render(dirname(__DIR__) . '/app/templates/views/browse.php', ['users' => $users, 'following' => $following]);
});this->users rather than just $users.Code: Select all
<?php if (!empty($this->users)): ?>
<ul class="users">
<?php foreach ($this->users as $user): ?>
<li>
<article>
<h4>
<a href="user.php?user=<?= $user->id; ?>">
<?= !empty($user->display_name) ? $user->display_name : $user->username; ?>
</a>
</h4>
<div class="following pull-right">
<?php if (in_array($user->id, $this->following)): ?>
<a href="unfollow.php?user=<?= $user->id; ?>" class="btn btn-danger">Unfollow</a>
<?php else: ?>
<a href="follow.php?user=<?= $user->id; ?>" class="btn btn-info">Follow</a>
<?php endif; ?>
</div>
</article>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>- no sidebar, full width content (as in the landing page)
- no sidebar, narrow content (edit profile page, browse when not logged in)
- sidebar, narrow content
yieldView line to the followingCode: Select all
<?php if ($this->sidebar): ?>
<div class="col-md-3">
<?php // Sidebar will go here ?>
</div>
<div class="col-md-6">
<?= $this->yieldView(); ?>
</div>
<?php else: ?>
<?= $this->yieldView(); ?>
<?php endif; ?>sidebar to our view and page should render as expected.Most of our remaining routes require us to be logged in, so let's tackle the login page next.
So we know we need to move the
login.php into our views directory, strip out the calls to session_start, dbconn, header and footer, but what about this $_POST business? Where does that belong? We have two options here; we can specify a single route to handle both rendering the view and handling form submissions, or we can separate it out into two separate routes, one for GET requests and one for POST requests. Either is fine, but I generally prefer the latter. We're looking at something like thisCode: Select all
$router->get('/login', function($request, $response, $service, $app) {
$service->sidebar = true;
$service->layout(dirname(__DIR__) . '/app/templates/layouts/main.php');
$service->render(dirname(__DIR__) . '/app/templates/views/login.php');
});
$router->post('/login', function($request, $response, $service, $app) use ($db) {
if (!empty($_POST)) {
$logged = $db->login($_POST['username'], $_POST['password']);
if ($logged === true) {
$_SESSION['username'] = $_POST['username'];
header('Location: home.php');
exit;
} else {
$error = "Incorrect username or password";
}
}
});index.php. What we want to do is point it to the appropriate resource. In this case, we're posting back to the same URI, so we can just call the current URI with $this->request->uri().The functionality technically works, but the redirect doesn't. We can clean up the code a little by moving away from superglobals ($
_POST, $_SESSION) and by leveraging Klein's redirect functionality instead of calling header directly. We don't need the Code: Select all
if (!empty($_POST))login. With a little refactoring, we've got something like thisCode: Select all
$router->post('/login', function($request, $response, $service, $app) use ($db) {
$logged = $db->login($request->param('username'), $request->param('password'));
if ($logged === true) {
$_SESSION['username'] = $request->param('username');
$response->redirect('/home');
} else {
$error = "Incorrect username or password";
}
});
$router->get('/home', function($request, $response, $service, $app) use ($db) {
echo 'Home'; exit;
});
_POST superglobal, pass them to our login function, and redirect to a temporary route. Everything works great. What if our credentials are wrong, though? We'll need to redirect the user back and display the error message. How do we pass that parameter through to our GET route? Here we're going to leverage flash messages. Simply put, these are messages that get stored in a user's session and are deleted immediately after having been displayed. More goodness that already comes into this lightweight but surprisingly feature-rich router.So we set our error message as a flash message, have our GET route check for such messages, and modify our view to display them
Code: Select all
$router->get('/login', function($request, $response, $service, $app) {
$error = $service->flashes('error');
$service->sidebar = true;
$service->layout(dirname(__DIR__) . '/app/templates/layouts/main.php');
$service->render(dirname(__DIR__) . '/app/templates/views/login.php', ['error' => $error]);
});
$router->post('/login', function($request, $response, $service, $app) use ($db) {
$logged = $db->login($request->param('username'), $request->param('password'));
if ($logged === true) {
$_SESSION['username'] = $request->param('username');
$response->redirect('/home');
} else {
$service->flash('Incorrect username or password', 'error');
$service->back();
}
});
Code: Select all
<?php if (isset($this->error)): ?>
<?php foreach ($this->error as $error): ?>
<p class="alert alert-danger"><?= $error; ?></p>
<?php endforeach; ?>
<?php endif; ?>
<form action="<?= $this->request->uri(); ?>" method="post">
<div class="form-group">
<label for="username">Username</label>
<input type="text" name="username" class="form-control">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" class="form-control">
</div>
<button class="btn btn-lg btn-block btn-primary">Login</button>
</form>The error is gone, our flash message is displaying as intended, and we're being redirected to our temporary home route on successful login. Let's work on fleshing out that route next.
As we've done a few times already, let's move
home.php out of our public webroot and into our views directory, strip out the session, dbconn, header, and footer calls, and start moving our domain logic into our route callback. Our trimmed down view now looks like thisCode: Select all
<form action="<?= $this->request->uri(); ?>" method="post">
<div class="form-group">
<textarea name="squeak" class="form-control" rows="5"></textarea>
</div>
<div class="form-group">
<button class="btn btn-primary pull-right">Squeak!</button>
</div>
</form>
<div class="clearfix"></div>
<?php if (!empty($this->squeaks)): ?>
<ul class="squeaks">
<?php foreach ($this->squeaks as $squeak): ?>
<li class="squeak-container">
<article class="squeak-body">
<a href="user.php?user=<?= $squeak->user_id; ?>">
<?= !empty($squeak->display_name) ? $squeak->display_name : $squeak->username; ?>
</a> ·
<time><?= $squeak->created_at; ?></time>
<div class="squeak"><?= $squeak->message; ?></div>
</article>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>Code: Select all
$router->get('/home', function($request, $response, $service, $app) use ($db) {
if (!isset($_SESSION['username'])) {
header('Location: login.php');
} else {
$user_id = $db->getUserID($_SESSION['username']);
}
if (!empty($_POST['squeak'])) {
$db->createSqueak($user_id, $_POST['squeak']);
}
$squeaks = $db->getSqueaksForHome($user_id);
$service->sidebar = true;
$service->layout(dirname(__DIR__) . '/app/templates/layouts/main.php');
$service->render(dirname(__DIR__) . '/app/templates/views/home.php', ['squeaks' => $squeaks]);
});header, and our $_POST logic should probably go in its own route definition. Beyond that, though, we're starting to see some repetition creeping its way into our routes. We're defining $sidebar in every route, we're specifying our layout in every route, and these are really things that are consistent throughout the app. We should be able to define them once and be done with them. We can accomplish this be defining a respond call without specifying a URI. As the router will execute every callback for route definitions that match the current request URI, this callback will get executed on every single request, which is really what we're after here.Code: Select all
$router->respond(function($request, $response, $service, $app) use ($db) {
$service->startSession();
$service->layout_dir = dirname(__DIR__) . '/app/templates/layouts/';
$service->views_dir = dirname(__DIR__) . '/app/templates/views/';
$service->sidebar = true;
$service->layout($service->layout_dir . 'main.php');
});Code: Select all
public function getUserSidebar($user_id) {
$profile = $this->getProfile($user_id);
$squeaks = $this->getSqueakCount($user_id);
$following = $this->getFollowingCount($user_id);
$followers = $this->getFollowerCount($user_id);
return [
'user_id' => $user_id,
'profile' => $profile,
'squeaks' => $squeaks,
'following' => $following,
'followers' => $followers,
];
}Code: Select all
if (isset($_SESSION['username'])) {
$user_id = $db->getUserID($_SESSION['username']);
$service->sidebar_data = $db->getUserSidebar($user_id);
}Code: Select all
<?php if (isset($this->sidebar_data)): ?>
<article class="userinfo">
<h3>
<?php if (isset($this->sidebar_data['profile']->display_name) && !empty($this->sidebar_data['profile']->display_name)): ?>
<?= "{$this->sidebar_data['profile']->display_name} ({$this->sidebar_data['profile']->username})"; ?>
<?php else: ?>
<?= $this->sidebar_data['profile']->username; ?>
<?php endif; ?>
</h3>
<?php if (!empty($this->sidebar_data['profile']->profile)): ?>
<div class="profile"><?= $this->sidebar_data['profile']->profile; ?></div>
<?php endif; ?>
<?php if (!empty($this->sidebar_data['profile']->website)): ?>
<div class="website"><a href="<?= $this->sidebar_data['profile']->website; ?>" target="_blank"><?= $this->sidebar_data['profile']->website; ?></a></div>
<?php endif; ?>
<div class="row stats">
<div class="col-md-4 text-center">
<a href="me.php">
<strong>Squeaks</strong>
<?= $this->sidebar_data['squeaks']; ?>
</a>
</div>
<div class="col-md-4 text-center">
<a href="following.php">
<strong>Following</strong>
<?= $this->sidebar_data['following']; ?>
</a>
</div>
<div class="col-md-4 text-center">
<a href="followers.php">
<strong>Followers</strong>
<?= $this->sidebar_data['followers']; ?>
</a>
</div>
</div>
</article>
<?php endif; ?>So the first 'something new' comes when converting the registration page. I'm using Klein's built in validator to check the form submission. When a rule fails, an exception is thrown, we catch that, and use the exception's message to return an error to the user. I have also defined a couple of new validation rules as the existing set doesn't entirely meet our needs.
Code: Select all
$router->post('/register', function($request, $response, $service, $app) use ($db) {
$service->addValidator('usernameAvailable', function ($value) use ($db) {
return $db->usernameAvailable($value);
});
$service->addValidator('matches', function($first, $second) {
return $first === $second;
});
try {
$service->validateParam('username', 'Username is required')->notNull();
$service->validateParam('password', 'Password is required')->notNull();
$service->validateParam('password_confirmation', 'Password confirmation is required')->notNull();
$service->validateParam('password_confirmation', 'Passwords do not match')->matches($request->param('password'));
$service->validateParam('username', 'That username is already taken')->usernameAvailable();
$created = $db->register($request->param('username'), $request->param('password'));
if ($created) {
$response->redirect('login');
}
} catch (Exception $e) {
$service->flash($e->getMessage(), 'error');
$service->back();
}
});user.php?user=123. We have replaced that with a nicer /user/123Code: Select all
$router->get('/user/[i:id]', function($request, $response, $service, $app) use ($db) {
$squeaks = $db->getSqueaksForUser($request->param('id'));
$service->sidebar_data = $db->getUserSidebar($request->param('id'));
$service->render($service->views_dir . 'user.php', ['squeaks' => $squeaks]);
});
All the routes have now been converted to be handled by our router. Most of the old page scripts have been trimmed down and moved to our views directory. Some, namely those that didn't display anything, have been done away with altogether. The route callback handles the functionality, there was no display, and we just redirect somewhere else once we're done. This leaves our public directory nice and clean and our app directory logically structured. There are still a few improvements we could make here, but we have already covered a lot of ground, so I think this is a good place to wrap up this lesson. We can tackle the clean up next time before diving into error handling.
Download lesson