Laravel 4 File-Based CMS

A Comprehensive Tutorial

Christopher Pitt
Laravel 4 Tutorials

--

OctoberCMS is a Laravel-based, pre-built CMS which was recently announced. I have yet to see the code powering what looks like a beautiful and efficient CMS system. So I thought I would try to implement some of the concepts presented in the introductory video as they illustrate valuable tips for working with Laravel.

While much effort has been spent in the pursuit of accuracy; there’s a good chance you could stumble across a curly quote in a code listing, or some other egregious errata. Please make a note and I will fix where needed.

I have also uploaded this code to Github. You need simply follow the configuration instructions in this tutorial, after downloading the source code, and the application should run fine.

This assumes, of course, that you know how to do that sort of thing. If not; this shouldn’t be the first place you learn about making PHP applications.

https://github.com/formativ/tutorial-laravel-4-file-based-cms

If you spot differences between this tutorial and that source code, please raise it here or as a GitHub issue. Your help is greatly appreciated.

Installing Dependencies

We’re developing a Laravel 4 application which has lots of server-side aspects; but there’s also an interactive interface. There be scripts!

For this; we’re using Bootstrap and jQuery. Download Bootstrap at: http://getbootstrap.com/ and unpack it into your public folder. Where you put the individual files makes little difference, but I have put the scripts in public/js, the stylesheets in public/css and the fonts in public/fonts. Where you see those paths in my source code; you should substitute them with your own.

Next up, download jQuery at: http://jquery.com/download/ and unpack it into your public folder.

On the server-side, we’re going to be using Flysystem for reading and writing files. Add it to the Composer dependencies:

"require" : {
"laravel/framework" : "4.1.*",
"league/flysystem" : "0.2.*"
},

This was extracted from composer.json.

Follow that up with:

composer update

Rendering Templates

We’ve often used View::make() to render views. It’s great for when we have pre-defined view files and we want Laravel to manage how they are rendered and stored. In this tutorial, we’re going to be rendering templates from strings. We’ll need to encapsulate some of how Laravel rendered templates, but it’ll also give us a good base for extending upon the Blade template syntax.

Let’s get started by creating a service provider:

php artisan workbench formativ/cms

This will generate the usual scaffolding for a new package. We need to add it in a few places, to be able to use it in our application:

"providers" => [
"Formativ\Cms\CmsServiceProvider",
// …remaining service providers
],

This was extracted from app/config/app.php.

"autoload": {
"classmap": [
// …
],
"psr-0": {
"Formativ\\Cms": "workbench/formativ/cms/src/"
}
}

This was extracted from composer.json.

Then we need to rebuild the composer autoloader:

composer dump-autoload

All this gets us to a place where we can start to add classes for encapsulating and extending Blade rendering. Let’s create some wrapper classes, and register them in the service provider:

<?php

namespace Formativ\Cms;

interface CompilerInterface
{
public function compileString($template);
}

This file should be saved as workbench/formativ/cms/src/
Formativ/Cms/CompilerInterface.php
.

<?php

namespace Formativ\Cms\Compiler;

use Formativ\Cms\CompilerInterface;
use Illuminate\View\Compilers\BladeCompiler;

class Blade
extends BladeCompiler
implements CompilerInterface
{

}

This file should be saved as workbench/formativ/cms/src/
Formativ/Cms/Compiler/Blade.php
.

<?php

namespace Formativ\Cms;

interface EngineInterface
{
public function render($template, $data);
}

This file should be saved as workbench/formativ/cms/src/
Formativ/Cms/EngineInterface.php
.

<?php

namespace Formativ\Cms\Engine;

use Formativ\Cms\CompilerInterface;
use Formativ\Cms\EngineInterface;

class Blade
implements EngineInterface
{
protected $compiler;

public function __construct(CompilerInterface $compiler)
{
$this->compiler = $compiler;
}

public function render($template, $data)
{
$compiled = $this->compiler->compileString($template);

ob_start();
extract($data, EXTR_SKIP);

try
{
eval("?>" . $compiled);
}
catch (Exception $e)
{
ob_end_clean();
throw $e;
}

$result = ob_get_contents();
ob_end_clean();

return $result;
}
}

This file should be saved as workbench/formativ/cms/src/
Formativ/Cms/Engine/Blade.php
.

<?php

namespace Formativ\Cms;

use Illuminate\Support\ServiceProvider;

class CmsServiceProvider
extends ServiceProvider
{
protected $defer = true;

public function register()
{
$this->app->bind(
"Formativ\Cms\CompilerInterface",
function() {
return new Compiler\Blade(
$this->app->make("files"),
$this->app->make("path.storage") . "/views"
);
}
);

$this->app->bind(
"Formativ\Cms\EngineInterface",
"Formativ\Cms\Engine\Blade"
);
}

public function provides()
{
return [
"Formativ\Cms\CompilerInterface",
"Formativ\Cms\EngineInterface"
];
}
}

This file should be saved as workbench/formativ/cms/src/
Formativ/Cms/CmsServiceProvider.php
.

The Compiler\Blade class encapsulates the BladeCompiler class, allowing us to implement the CompilerInterface interface. This is a way of future-proofing our package so that the code which depends on methods Blade currently implements won’t fail if future versions of Blade were to remove that implementation.

We can also add additional template tags in via this class, so it’s not too much code bloat.

The Engine\Blade class contains the method which we will use to render template strings. It implements the EngineInterface interface for that same future-proofing.

We register all of these in the CmsServiceProvider. We can now inject these dependencies in our controller:

<?php

use Formativ\Cms\EngineInterface;

class IndexController
extends BaseController
{
protected $engine;

public function __construct(EngineInterface $engine)
{
$this->engine = $engine;
}

public function indexAction()
{
// ...use $this->engine->render() here
}
}

This file should be saved as app/controllers/IndexController.php.

As we bound Formativ\Cms\EngineInterface in our service provider, we can now specify it in our controller constructor and Laravel will automatically inject it for us.

Gathering Metadata

One of the interesting things OctoberCMS does is store all of the page and layout meta data at the top of the template file. This allows changes to metadata (which would normally take place elsewhere) to be version-controlled. Having gained the ability to render template strings, we’re now in a position to be able to isolate this kind of metadata and render the rest of the file as a template.

Consider the following example:

protected function minify($html)
{
$search = [
"/\>[^\S ]+/s",
"/[^\S ]+\</s",
"/(\s)+/s"
];

$replace = [
">",
"<",
"\\1"
];

$html = preg_replace($search, $replace, $html);

return $html;
}

public function indexAction()
{
$template = $this->minify("
<!doctype html>
<html lang='en'>
<head>
<title>
Laravel 4 File-Based CMS
</title>
</head>
<body>
Hello world
</body>
</html>
");

return $this->engine->render($template, []);
}

This was extracted from app/controllers/IndexController.php.

Here we’re rendering a page template (with the help of a minify method). It’s just like what we did before. Let’s add some metadata, and pull it out of the template before rendering:

protected function extractMeta($html)
{
$parts = explode("==", $html, 2);

$meta = "";
$html = $parts[0];

if (count($parts) > 1)
{
$meta = $parts[0];
$html = $parts[1];
}

return [
"meta" => $meta,
"html" => $html
];
}

protected function parseMeta($meta)
{
$meta = trim($meta);
$lines = explode("\n", $meta);
$data = [];

foreach ($lines as $line)
{
$parts = explode("=", $line);
$data[trim($parts[0])] = trim($parts[1]);
}

return $data;
}

public function indexAction()
{
$parts = $this->extractMeta("
title = Laravel 4 File-Based CMS
message = Hello world
==
<!doctype html>
<html lang='en'>
<head>
<title>
{{ \$title }}
</title>
</head>
<body>
{{ \$message }}
</body>
</html>
");

$data = $this->parseMeta($parts["meta"]);
$template = $this->minify($parts["html"]);

return $this->engine->render($template, $data);
}

This was extracted from app/controllers/IndexController.php.

This time round, we’re using an extractMeta() method to pull the meta data string out of the template string, and a parseMeta() method to split the lines of metadata into key/value pairs.

The result is a functional means of storing and parsing meta data, and rendering the remaining template from and to a string.

The minify method is largely unmodified from the original, which I found at: http://stackoverflow.com/questions/6225351/how-to-minify-php-page-html-output.

Creating Layouts

We need to create some sort of admin interface, with which to create and/or modify pages and layouts. Let’s skip the authentication system (as we’ve done that before and it will distract from the focus of this tutorial).

I’ve chosen for us to use Flysystem when working with the filesystem. It would be a good idea to future-proof this dependency by wrapping it in a subclass which implements an interface we control.

<?php

namespace Formativ\Cms;

interface FilesystemInterface
{
public function has($file);
public function listContents($folder, $detail = false);
public function write($file, $contents);
public function read($file);
public function put($file, $content);
public function delete($file);
}

This file should be saved as workbench/formativ/cms/src/
Formativ/Cms/FilesystemInterface.php
.

<?php

namespace Formativ\Cms;

use League\Flysystem\Filesystem as Base;

class Filesystem
extends Base
implements FilesystemInterface
{

}

This file should be saved as workbench/formativ/cms/src/
Formativ/Cms/Filesystem.php
.

public function register()
{
$this->app->bind(
"Formativ\Cms\CompilerInterface",
function() {
return new Compiler\Blade(
$this->app->make("files"),
$this->app->make("path.storage") . "/views"
);
}
);

$this->app->bind(
"Formativ\Cms\EngineInterface",
"Formativ\Cms\Engine\Blade"
);

$this->app->bind(
"Formativ\Cms\FilesystemInterface",
function() {
return new Filesystem(
new Local(
$this->app->make("path.base") . "/app/views"
)
);
}
);
}

This was extracted from workbench/formativ/cms/src/Formativ/
Cms/CmsServiceProvider.php
.

We’re not really adding any extra functionality to that which Flysystem provides. The sole purpose of us wrapping the Local Flysystem adapter is to make provision for swapping it with another filesystem class/library.

We should also move the metadata-related functionality into a better location.

<?php

namespace Formativ\Cms;

interface EngineInterface
{
public function render($template, $data);
public function extractMeta($template);
public function parseMeta($meta);
public function minify($template);
}

This file should be saved as workbench/formativ/cms/src/
Formativ/Cms/EngineInterface.php
.

<?php

namespace Formativ\Cms\Engine;

use Formativ\Cms\CompilerInterface;
use Formativ\Cms\EngineInterface;

class Blade
implements EngineInterface
{
protected $compiler;

public function __construct(CompilerInterface $compiler)
{
$this->compiler = $compiler;
}

public function render($template, $data)
{
$extracted = $this->extractMeta($template)["template"];
$compiled = $this->compiler->compileString($extracted);

ob_start();
extract($data, EXTR_SKIP);

try
{
eval("?>" . $compiled);
}
catch (Exception $e)
{
ob_end_clean();
throw $e;
}

$result = ob_get_contents();
ob_end_clean();

return $result;
}

public function minify($template)
{
$search = [
"/\>[^\S ]+/s",
"/[^\S ]+\</s",
"/(\s)+/s"
];

$replace = [
">",
"<",
"\\1"
];

$template = preg_replace($search, $replace, $template);

return $template;
}

public function extractMeta($template)
{
$parts = explode("==", $template, 2);

$meta = "";
$template = $parts[0];

if (count($parts) > 1)
{
$meta = $parts[0];
$template = $parts[1];
}

return [
"meta" => $meta,
"template" => $template
];
}

public function parseMeta($meta)
{
$meta = trim($meta);
$lines = explode("\n", $meta);
$data = [];

foreach ($lines as $line)
{
$parts = explode("=", $line);
$data[trim($parts[0])] = trim($parts[1]);
}

return $data;
}
}

This file should be saved as workbench/formativ/cms/src/
Formativ/Cms/Engine/Blade.php
.

The only difference can be found in the argument names (to bring them more in line with the rest of the class) and integrating the meta methods into the render() method.

Next up is layout controller class:

<?php

use Formativ\Cms\EngineInterface;
use Formativ\Cms\FilesystemInterface;

class LayoutController
extends BaseController
{
protected $engine;
protected $filesystem;

public function __construct(
EngineInterface $engine,
FilesystemInterface $filesystem
)
{
$this->engine = $engine;
$this->filesystem = $filesystem;

Validator::extend(
"add",
function($attribute, $value, $params) {
return !$this->filesystem->has("layouts/" . $value);
}
);

Validator::extend(
"edit",
function($attribute, $value, $params) {
$new = !$this->filesystem->has("layouts/" . $value);
$same = $this->filesystem->has("layouts/" . $params[0]);

return $new or $same;
}
);
}

public function indexAction()
{
$layouts = $this->filesystem->listContents("layouts");
$edit = URL::route("admin/layout/edit") . "?layout=";
$delete = URL::route("admin/layout/delete") . "?layout=";

return View::make("admin/layout/index", compact(
"layouts",
"edit",
"delete"
));
}
}

This file should be saved as app/controllers/LayoutController.php.

This is the first time we’re using Dependency Injection in our controller. Laravel is injecting our engine interface (which is the Blade wrapper) and our filesystem interface (which is the Flysystem wrapper). As usual, we assign the injected dependencies to protected properties. We also define two custom validation rules, which we’ll use when adding and editing the layout files.

We’ve also defined an indexAction() method which will be used to display a list of layout files which can then be edited or deleted. For the interface to be complete, we are going to need the following files:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Laravel 4 File-Based CMS</title>
<link
rel="stylesheet"
href="{{ asset("css/bootstrap.min.css"); }}"
/>
<link
rel="stylesheet"
href="{{ asset("css/shared.css"); }}"
/>
</head>
<body>
@include("admin/include/navigation")
<div class="container">
<div class="row">
<div class="column md-12">
@yield("content")
</div>
</div>
</div>
<script src="{{ asset("js/jquery.min.js"); }}"></script>
<script src="{{ asset("js/bootstrap.min.js"); }}"></script>
</body>
</html>

This file should be saved as app/views/admin/layout.blade.php.

<nav
class="navbar navbar-inverse navbar-fixed-top"
role="navigation"
>
<div class="container-fluid">
<div class="navbar-header">
<button type="button"
class="navbar-toggle"
data-toggle="collapse"
data-target="#navbar-collapse"
>
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
</div>
<div
class="collapse navbar-collapse"
id="navbar-collapse"
>
<ul class="nav navbar-nav">
<li class="@yield("navigation/layout/class")">
<a href="{{ URL::route("admin/layout/index") }}">
Layouts
</a>
</li>
</div>
</div>
</nav>

This file should be saved as app/views/admin/include/navigation.blade.php.

<ol class="breadcrumb">
<li>
<a href="{{ URL::route("admin/layout/index") }}">
List Layouts
</a>
</li>
<li>
<a href="{{ URL::route("admin/layout/add") }}">
Add New Layout
</a>
</li>
</ol>

This file should be saved as app/views/admin/include/
layout/navigation.blade.php
.

@extends("admin/layout")
@section("navigation/layout/class")
active
@stop
@section("content")
@include("admin/include/layout/navigation")
@if (count($layouts))
<table class="table table-striped">
<thead>
<tr>
<th class="wide">
File
</th>
<th class="narrow">
Actions
</th>
</tr>
</thead>
<tbody>
@foreach ($layouts as $layout)
@if ($layout["type"] == "file")
<tr>
<td class="wide">
<a href="{{ $edit . $layout["basename"] }}">
{{ $layout["basename"] }}
</a>
</td>
<td class="narrow actions">
<a href="{{ $edit . $layout["basename"] }}">
<i class="glyphicon glyphicon-pencil"></i>
</a>
<a href="{{ $delete . $layout["basename"] }}">
<i class="glyphicon glyphicon-trash"></i>
</a>
</td>
</tr>
@endif
@endforeach
</tbody>
</table>
@else
No layouts yet.
<a href="{{ URL::route("admin/layout/add") }}">
create one now!
</a>
@endif
@stop

This file should be saved as app/views/admin/layout/index.blade.php.

Route::any("admin/layout/index", [
"as" => "admin/layout/index",
"uses" => "LayoutController@indexAction"
]);

This was extracted from app/routes.php.

These are quite a few files, so let’s go over them individually:

  1. The first file is the main admin layout template. Every page in the admin area should be rendered within this layout template. As you can see, we’ve linked the jQuery and Bootstrap assets to provide some styling and interactive functionality to the admin area.
  2. The second file is the main admin navigation template. This will also be present on every page in the admin area, though it’s better to include it in the main layout template than to clutter the main layout template with secondary markup.
  3. The third file is a sub-navigation template, only present in the pages concerning layouts.
  4. The fourth file is the layout index (or listing) page. It includes the sub-navigation and renders a table row for each layout file it finds. If none can be found, it will present a cheeky message for the user to add one.
  5. Finally we add the index route to the routes.php file. At this point, the page should be visible via the browser. As there aren’t any layout files yet, you should see the cheeky message instead.

Let’s move onto the layout add page:

public function addAction()
{
if (Input::has("save"))
{
$validator = Validator::make(Input::all(), [
"name" => "required|add",
"code" => "required"
]);

if ($validator->fails())
{
return Redirect::route("admin/layout/add")
->withInput()
->withErrors($validator);
}

$meta = "
title = " . Input::get("title") . "
description = " . Input::get("description") . "
==
";

$name = "layouts/" . Input::get("name") . ".blade.php";

$this->filesystem->write($name, $meta . Input::get("code"));

return Redirect::route("admin/layout/index");
}

return View::make("admin/layout/add");
}

This was extracted from app/controllers/LayoutController.php.

@extends("admin/layout")
@section("navigation/layout/class")
active
@stop
@section("content")
@include("admin/include/layout/navigation")
<form role="form" method="post">
<div class="form-group">
<label for="name">Name</label>
<span class="help-text text-danger">
{{ $errors->first("name") }}
</span>
<input
type="text"
class="form-control"
id="name"
name="name"
placeholder="new-layout"
value="{{ Input::old("name") }}"
/>
</div>
<div class="form-group">
<label for="title">Meta Title</label>
<input
type="text"
class="form-control"
id="title"
name="title"
value="{{ Input::old("title") }}"
/>
</div>
<div class="form-group">
<label for="description">Meta Description</label>
<input
type="text"
class="form-control"
id="description"
name="description"
value="{{ Input::old("description") }}"
/>
</div>
<div class="form-group">
<label for="code">Code</label>
<span class="help-text text-danger">
{{ $errors->first("code") }}
</span>
<textarea
class="form-control"
id="code"
name="code"
rows="5"
placeholder="&lt;div&gt;Hello world&lt;/div&gt;"
>{{ Input::old("code") }}</textarea>
</div>
<input
type="submit"
name="save"
class="btn btn-default"
value="Save"
/>
</form>
@stop

This file should be saved as app/views/admin/layout/add.blade.php.

Route::any("admin/layout/add", [
"as" => "admin/layout/add",
"uses" => "LayoutController@addAction"
]);

This was extracted from app/routes.php.

The form processing, in the addAction() method, is wrapped in a check for the save parameter. This is the name of the submit button on the add form. We specify the validation rules (including one of those we defined in the constructor). If validation fails, we redirect back to the add page, bringing along the errors and old input. If not, we create a new file with the default meta title and default meta description as metadata. Finally we redirect to the index page.

The view is fairly standard (including the bootstrap tags we’ve used). The name and code fields have error messages and all of the fields have their values set to the old input values. We’ve also added a route to the add page.

Edit follows a similar pattern:

public function editAction()
{
$layout = Input::get("layout");
$name = str_ireplace(".blade.php", "", $layout);
$content = $this->filesystem->read("layouts/" . $layout);
$extracted = $this->engine->extractMeta($content);
$code = trim($extracted["template"]);
$parsed = $this->engine->parseMeta($extracted["meta"]);
$title = $parsed["title"];
$description = $parsed["description"];

if (Input::has("save"))
{
$validator = Validator::make(Input::all(), [
"name" => "required|edit:" . Input::get("layout"),
"code" => "required"
]);

if ($validator->fails())
{
return Redirect::route("admin/layout/edit")
->withInput()
->withErrors($validator);
}

$meta = "
title = " . Input::get("title") . "
description = " . Input::get("description") . "
==
";

$name = "layouts/" . Input::get("name") . ".blade.php";

$this->filesystem->put($name, $meta . Input::get("code"));

return Redirect::route("admin/layout/index");
}

return View::make("admin/layout/edit", compact(
"name",
"title",
"description",
"code"
));
}

This was extracted from app/controllers/LayoutController.php.

@extends("admin/layout")
@section("navigation/layout/class")
active
@stop
@section("content")
@include("admin/include/layout/navigation")
<form role="form" method="post">
<div class="form-group">
<label for="name">Name</label>
<span class="help-text text-danger">
{{ $errors->first("name") }}
</span>
<input
type="text"
class="form-control"
id="name"
name="name"
placeholder="new-layout"
value="{{ Input::old("name", $name) }}"
/>
</div>
<div class="form-group">
<label for="title">Meta Title</label>
<input
type="text"
class="form-control"
id="title"
name="title"
value="{{ Input::old("title", $title) }}"
/>
</div>
<div class="form-group">
<label for="description">Meta Description</label>
<input
type="text"
class="form-control"
id="description"
name="description"
value="{{ Input::old("description", $description) }}"
/>
</div>
<div class="form-group">
<label for="code">Code</label>
<span class="help-text text-danger">
{{ $errors->first("code") }}
</span>
<textarea
class="form-control"
id="code"
name="code"
rows="5"
placeholder="&lt;div&gt;Hello world&lt;/div&gt;"
>{{ Input::old("code", $code) }}</textarea>
</div>
<input
type="submit"
name="save"
class="btn btn-default"
value="Save"
/>
</form>
@stop

This file should be saved as app/views/admin/layout/edit.blade.php.

Route::any("admin/layout/edit", [
"as" => "admin/layout/edit",
"uses" => "LayoutController@editAction"
]);

This was extracted from app/routes.php.

The editAction() method fetches the layout file data and extracts/parses the metadata, so that we can present it in the edit form. Other than utilising the second custom validation function, we define in the constructor, there’s nothing else noteworthy in this method.

The edit form is also pretty much the same, except that we provide default values to the Input::old() method calls, giving the data extracted from the layout file. We also add a route to the edit page.

You may notice that the file name remains editable, on the edit page. When you change this value, and save the layout it won’t change the name of the current layout file, but rather generate a new layout file. This is an interesting (and in this case useful) side-effect of using the file name as the unique identifier for layout files.

Deleting layout files is even simpler:

public function deleteAction()
{
$name = "layouts/" . Input::get("layout");
$this->filesystem->delete($name);

return Redirect::route("admin/layout/index");
}

This was extracted from app/controllers/LayoutController.php.

Route::any("admin/layout/delete", [
"as" => "admin/layout/delete",
"uses" => "LayoutController@deleteAction"
]);

This was extracted from app/routes.php.

We link straight to the deleteAction() method in the index view. This method simply deletes the layout file and redirects back to the index page. We’ve added the appropriate route to make this page accessible.

We can now list the layout files, add new ones, edit existing ones and delete those layout files we no longer require. It’s basic, and could definitely be polished a bit, but it’s sufficient for our needs.

Creating Pages

Pages are handled in much the same way, so we’re not going to spend too much time on them. Let’s begin with the controller:

<?php

use Formativ\Cms\EngineInterface;
use Formativ\Cms\FilesystemInterface;

class PageController
extends BaseController
{
protected $engine;
protected $filesystem;

public function __construct(
EngineInterface $engine,
FilesystemInterface $filesystem
)
{
$this->engine = $engine;
$this->filesystem = $filesystem;

Validator::extend(
"add",
function($attribute, $value, $parameters) {
return !$this->filesystem->has("pages/" . $value);
}
);

Validator::extend(
"edit",
function($attribute, $value, $params) {
$new = !$this->filesystem->has("pages/" . $value);
$same = $this->filesystem->has("pages/" . $params[0]);

return $new or $same;
}
);
}

public function indexAction()
{
$pages = $this->filesystem->listContents("pages");
$edit = URL::route("admin/page/edit") . "?page=";
$delete = URL::route("admin/page/delete") . "?page=";

return View::make("admin/page/index", compact(
"pages",
"edit",
"delete"
));
}

public function addAction()
{
$files = $this->filesystem->listContents("layouts");
$layouts = [];

foreach ($files as $file)
{
$name = $file["basename"];
$layouts[$name] = $name;
}

if (Input::has("save"))
{
$validator = Validator::make(Input::all(), [
"name" => "required|add",
"route" => "required",
"layout" => "required",
"code" => "required"
]);

if ($validator->fails())
{
return Redirect::route("admin/page/add")
->withInput()
->withErrors($validator);
}

$meta = "
title = " . Input::get("title") . "
description = " . Input::get("description") . "
layout = " . Input::get("layout") . "
route = " . Input::get("route") . "
==
";

$name = "pages/" . Input::get("name") . ".blade.php";
$code = $meta . Input::get("code");

$this->filesystem->write($name, $code);

return Redirect::route("admin/page/index");
}

return View::make("admin/page/add", compact(
"layouts"
));
}

public function editAction()
{
$files = $this->filesystem->listContents("layouts");
$layouts = [];

foreach ($files as $file)
{
$name = $file["basename"];
$layouts[$name] = $name;
}

$page = Input::get("page");
$name = str_ireplace(".blade.php", "", $page);
$content = $this->filesystem->read("pages/" . $page);
$extracted = $this->engine->extractMeta($content);
$code = trim($extracted["template"]);
$parsed = $this->engine->parseMeta($extracted["meta"]);
$title = $parsed["title"];
$description = $parsed["description"];
$route = $parsed["route"];
$layout = $parsed["layout"];

if (Input::has("save"))
{
$validator = Validator::make(Input::all(), [
"name" => "required|edit:" . Input::get("page"),
"route" => "required",
"layout" => "required",
"code" => "required"
]);

if ($validator->fails())
{
return Redirect::route("admin/page/edit")
->withInput()
->withErrors($validator);
}

$meta = "
title = " . Input::get("title") . "
description = " . Input::get("description") . "
layout = " . Input::get("layout") . "
route = " . Input::get("route") . "
==
";

$name = "pages/" . Input::get("name") . ".blade.php";
$code = $meta . Input::get("code");

$this->filesystem->put($name, $code);

return Redirect::route("admin/page/index");
}

return View::make("admin/page/edit", compact(
"name",
"title",
"description",
"layout",
"layouts",
"route",
"code"
));
}

public function deleteAction()
{
$name = "pages/" . Input::get("page");
$this->filesystem->delete($name);

return Redirect::route("admin/page/index");
}
}

This file should be saved as app/controllers/PageController.php.

The constructor method accepts the same injected dependencies as our layout controller did. We also define similar custom validation rules to check the names of files we want to save.

The addAction() method differs slightly in that we load the existing layout files so that we can designate the layout for each page. We also add this (and the route parameter) to the metadata saved to the page file.

The editAction() method loads the route and layout parameters (in addition to the other fields) and passes them to the edit page template, where they will be used to populate the new fields.

<nav
class="navbar navbar-inverse navbar-fixed-top"
role="navigation"
>
<div class="container-fluid">
<div class="navbar-header">
<button type="button"
class="navbar-toggle"
data-toggle="collapse"
data-target="#navbar-collapse"
>
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav">
<li class="@yield("navigation/layout/class")">
<a href="{{ URL::route("admin/layout/index") }}">
Layouts
</a>
</li>
<li class="@yield("navigation/page/class")">
<a href="{{ URL::route("admin/page/index") }}">
Pages
</a>
</li>
</ul>
</div>
</div>
</nav>

This file should be saved as app/views/admin/include/navigation.blade.php.

<ol class="breadcrumb">
<li>
<a href="{{ URL::route("admin/page/index") }}">
List Pages
</a>
</li>
<li>
<a href="{{ URL::route("admin/page/add") }}">
Add New Page
</a>
</li>
</ol>

This file should be saved as app/views/admin/include/
page/navigation.blade.php
.

@extends("admin/layout")
@section("navigation/page/class")
active
@stop
@section("content")
@include("admin/include/page/navigation")
@if (count($pages))
<table class="table table-striped">
<thead>
<tr>
<th class="wide">
File
</th>
<th class="narrow">
Actions
</th>
</tr>
</thead>
<tbody>
@foreach ($pages as $page)
@if ($page["type"] == "file")
<tr>
<td class="wide">
<a href="{{ $edit . $page["basename"] }}">
{{ $page["basename"] }}
</a>
</td>
<td class="narrow actions">
<a href="{{ $edit . $page["basename"] }}">
<i class="glyphicon glyphicon-pencil"></i>
</a>
<a href="{{ $delete . $page["basename"] }}">
<i class="glyphicon glyphicon-trash"></i>
</a>
</td>
</tr>
@endif
@endforeach
</tbody>
</table>
@else
No pages yet.
<a href="{{ URL::route("admin/page/add") }}">
create one now!
</a>
@endif
@stop

This file should be saved as app/views/admin/page/index.blade.php.

@extends("admin/layout")
@section("navigation/page/class")
active
@stop
@section("content")
@include("admin/include/page/navigation")
<form role="form" method="post">
<div class="form-group">
<label for="name">Name</label>
<span class="help-text text-danger">
{{ $errors->first("name") }}
</span>
<input
type="text"
class="form-control"
id="name"
name="name"
placeholder="new-page"
value="{{ Input::old("name") }}"
/>
</div>
<div class="form-group">
<label for="route">Route</label>
<span class="help-text text-danger">
{{ $errors->first("route") }}
</span>
<input
type="text"
class="form-control"
id="route"
name="route"
placeholder="/new-page"
value="{{ Input::old("route") }}"
/>
</div>
<div class="form-group">
<label for="layout">Layout</label>
<span class="help-text text-danger">
{{ $errors->first("layout") }}
</span>
{{ Form::select(
"layout",
$layouts,
Input::old("layout"),
[
"id" => "layout",
"class" => "form-control"
]
) }}
</div>
<div class="form-group">
<label for="title">Meta Title</label>
<input
type="text"
class="form-control"
id="title"
name="title"
value="{{ Input::old("title") }}"
/>
</div>
<div class="form-group">
<label for="description">Meta Description</label>
<input
type="text"
class="form-control"
id="description"
name="description"
value="{{ Input::old("description") }}"
/>
</div>
<div class="form-group">
<label for="code">Code</label>
<span class="help-text text-danger">
{{ $errors->first("code") }}
</span>
<textarea
class="form-control"
id="code"
name="code"
rows="5"
placeholder="&lt;div&gt;Hello world&lt;/div&gt;"
>{{ Input::old("code") }}</textarea>
</div>
<input
type="submit"
name="save"
class="btn btn-default"
value="Save"
/>
</form>
@stop

This file should be saved as app/views/admin/page/add.blade.php.

@extends("admin/layout")
@section("navigation/page/class")
active
@stop
@section("content")
@include("admin/include/page/navigation")
<form role="form" method="post">
<div class="form-group">
<label for="name">Name</label>
<span class="help-text text-danger">
{{ $errors->first("name") }}
</span>
<input
type="text"
class="form-control"
id="name"
name="name"
placeholder="new-page"
value="{{ Input::old("name", $name) }}"
/>
</div>
<div class="form-group">
<label for="route">Route</label>
<span class="help-text text-danger">
{{ $errors->first("route") }}
</span>
<input
type="text"
class="form-control"
id="route"
name="route"
placeholder="/new-page"
value="{{ Input::old("route", $route) }}"
/>
</div>
<div class="form-group">
<label for="layout">Layout</label>
<span class="help-text text-danger">
{{ $errors->first("layout") }}
</span>
{{ Form::select(
"layout",
$layouts,
Input::old("layout", $layout),
[
"id" => "layout",
"class" => "form-control"
]
) }}
</div>
<div class="form-group">
<label for="title">Meta Title</label>
<input
type="text"
class="form-control"
id="title"
name="title"
value="{{ Input::old("title", $title) }}"
/>
</div>
<div class="form-group">
<label for="description">Meta Description</label>
<input
type="text"
class="form-control"
id="description"
name="description"
value="{{ Input::old("description", $description) }}"
/>
</div>
<div class="form-group">
<label for="code">Code</label>
<span class="help-text text-danger">
{{ $errors->first("code") }}
</span>
<textarea
class="form-control"
id="code"
name="code"
rows="5"
placeholder="&lt;div&gt;Hello world&lt;/div&gt;"
>{{ Input::old("code", $code) }}</textarea>
</div>
<input
type="submit"
name="save"
class="btn btn-default"
value="Save"
/>
</form>
@stop

This file should be saved as app/views/admin/page/edit.blade.php.

The views follow a similar pattern to those which we created for managing layout files. The exception is that we add the new layout and route fields to the add and edit page templates. We’ve used the Form::select() method to render and select the appropriate layout.

Route::any("admin/page/index", [
"as" => "admin/page/index",
"uses" => "PageController@indexAction"
]);

Route::any("admin/page/add", [
"as" => "admin/page/add",
"uses" => "PageController@addAction"
]);

Route::any("admin/page/edit", [
"as" => "admin/page/edit",
"uses" => "PageController@editAction"
]);

Route::any("admin/page/delete", [
"as" => "admin/page/delete",
"uses" => "PageController@deleteAction"
]);

This was extracted from app/routes.php.

Finally, we add the routes which will allow us to access these pages. With all this in place, we can work on displaying the website content.

Displaying Content

Aside from our admin pages, we need to be able to catch the all requests, and route them to a single controller/action. We do this by appending the following route to the routes.php file:

Route::any("{all}", [
"as" => "index/index",
"uses" => "IndexController@indexAction"
])->where("all", ".*");

This was extracted from app/routes.php.

We pass all route information to a named parameter ({all}), which will be mapped to the IndexController::indexAction() method. We also need to specify the regular expression with which the route data should be matched. With ”.*” we’re telling Laravel to match absolutely anything. This is why this route needs to come right at the end of the app/routes.php file.

<?php

use Formativ\Cms\EngineInterface;
use Formativ\Cms\FilesystemInterface;

class IndexController
extends BaseController
{
protected $engine;
protected $filesystem;

public function __construct(
EngineInterface $engine,
FilesystemInterface $filesystem
)
{
$this->engine = $engine;
$this->filesystem = $filesystem;
}

protected function parseFile($file)
{
return $this->parseContent(
$this->filesystem->read($file["path"]),
$file
);
}

protected function parseContent($content, $file = null)
{
$extracted = $this->engine->extractMeta($content);
$parsed = $this->engine->parseMeta($extracted["meta"]);

return compact("file", "content", "extracted", "parsed");
}

protected function stripExtension($name)
{
return str_ireplace(".blade.php", "", $name);
}

protected function cleanArray($array)
{
return array_filter($array, function($item) {
return !empty($item);
});
}

public function indexAction($route = "/")
{
$pages = $this->filesystem->listContents("pages");

foreach ($pages as $page)
{
if ($page["type"] == "file")
{
$page = $this->parseFile($page);

if ($page["parsed"]["route"] == $route)
{
$basename = $page["file"]["basename"];
$name = "pages/extracted/" . $basename;
$layout = $page["parsed"]["layout"];
$layoutName = "layouts/extracted/" . $layout;
$extends = $this->stripExtension($layoutName);

$template = "
@extends('" . $extends . "')
@section('page')
" . $page["extracted"]["template"] . "
@stop
";

$this->filesystem->put($name, trim($template));

$layout = "layouts/" . $layout;

$layout = $this->parseContent(
$this->filesystem->read($layout)
);

$this->filesystem->put(
$layoutName,
$layout["extracted"]["template"]
);

$data = array_merge(
$this->cleanArray($layout["parsed"]),
$this->cleanArray($page["parsed"])
);

return View::make(
$this->stripExtension($name),
$data
);
}
}
}
}
}

This file should be saved as app/controllers/IndexController.php.

In the IndexController class, we’ve injected the same two dependencies: $filesystem and $engine. We need these to fetch and extract the template data.

We begin by fetching all the files in the app/views/pages directory. We iterate through them filtering out all the returned items which have a type of file. From these, we fetch the metadata and check if the route defined matches that which is being requested.

If there is a match, we extract the template data and save it to a new file, resembling app/views/pages/extracted/[original name]. We then fetch the layout defined in the metadata, performing a similar transformation. We do this because we still want to run the templates through Blade (so that @extends, @include, @section etc.) all still work as expected.

We filter the page metadata and the layout metadata, to omit any items which do not have values, and we pass the merged array to the view. Blade takes over and we have a rendered view!

Extending The CMS

We’ve implemented the simplest subset of October functionality. There’s a lot more going on that I would love to implement, but we’ve run out of time to do so. If you’ve found this project interesting, perhaps you would like to take a swing at implementing partials (they’re not much work if you prevent them from having metadata). Or perhaps you’re into JavaScript and want to try your hand at emulating some of the Ajax framework magic that October’s got going on…

You can learn more about OctoberCMS’s features at: http://octobercms.com/docs/cms/themes.

Conclusion

If you enjoyed this tutorial; it would be helpful if you could click the Recommend button.

This tutorial comes from a book I’m writing. If you like it and want to support future tutorials; please consider buying it. Half of all sales go to Laravel.

--

--