Laravel 4.0 shipped in May. By the end of September I had read enough about it to have opinions and written none of it, which is the worst state to be in. So one section of the internal admin panel — five years old, used by nine people, not customer-facing — got rebuilt on it: suppliers and stock levels, three tables, four screens and a permission rule. This is an evaluation with a deadline attached, not the first step of a migration plan, and the difference matters at the end.
The symptom
Two things about the old panel had been irritating for years, and both are the same absence wearing different clothes.
The schema lives in a wiki page. There are no migration files, so the canonical description of the suppliers table is a page last edited in 2011 and the actual description is whatever SHOW CREATE TABLE says on production. Setting up a development database means asking somebody for a dump.
The access rules live in copy-pasted if statements. This block, or something within two words of it, appears in eleven controller methods:
$user = $this->session->userdata('user');
if (! $user || ! in_array($user['role'], array('admin', 'buyer'))) {
redirect('login');
}
if ($user['role'] === 'buyer' && $this->input->post('cost_price')) {
show_error('Not permitted', 403);
}
Eleven copies means eleven places to change, and the copy that gets missed is always the one that grants access rather than the one that denies it — nothing visible goes wrong when a check is too permissive.
Why it happens
Neither is a failure of discipline. The old panel is CodeIgniter 2, which has no migration story worth the name, no route-level filter and no opinion about where authorisation belongs. So the schema went where schemas go when nothing owns them, and the permission check went into the only place guaranteed to run, which is the top of every method.
That is the question actually worth asking about a new framework. Not whether it can do a thing — they all can — but whether it makes the right thing cheaper than the wrong thing. Everything below is a test of that.
The fix
The schema goes into the repository
Migrations first, before any screen exists, because this is the part that pays for itself even if the rest of the evaluation ends badly.
$ php artisan migrate:make create_suppliers_table --create=suppliers
Created Migration: 2013_09_16_142211_create_suppliers_table
$ php artisan migrate
Migrated: 2013_09_16_142211_create_suppliers_table
Migrated: 2013_09_16_145803_create_stock_levels_tablepublic function up()
{
Schema::create('suppliers', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 120);
$table->string('account_ref', 40)->unique();
$table->boolean('active')->default(true);
$table->timestamps();
});
}
Then a seeder, so a fresh database is one command rather than a favour. The seed data is deliberately not a copy of production: nine suppliers and one deliberately broken record, which is what you want in front of you while building the screens anyway.
Tip
migrate --seed and migrate:refresh are what make this stick. If rebuilding the database from nothing takes more than one command, people keep using the old one, and the migrations drift away from reality inside a month.
Eloquent for the CRUD, and where it stops
Four screens is what an ORM is for. A model, a relationship, and the list-edit-save cycle stops being typed out by hand.
class Supplier extends Eloquent
{
protected $table = 'suppliers';
protected $fillable = array('name', 'account_ref', 'active');
public function stockLevels()
{
return $this->hasMany('StockLevel');
}
}
$suppliers = Supplier::with('stockLevels')
->where('active', true)
->orderBy('name')
->paginate(50);
with() is load-bearing there: without it the list screen issues one query per supplier, which on fifty rows is invisible in development and obvious on the day somebody adds the four hundredth.
The stock report is where I stopped. It is a three-table aggregate with a HAVING clause, and every attempt to express it through the query builder produced something longer than the SQL and harder to read. So it stayed as SQL:
$rows = DB::select(
'SELECT s.name, COUNT(l.id) AS lines, SUM(l.on_hand * l.cost) AS value
FROM suppliers s
JOIN stock_levels l ON l.supplier_id = s.id
WHERE s.active = 1
GROUP BY s.id
HAVING value > ?
ORDER BY value DESC',
array($threshold)
);
An ORM that lets you drop to SQL without leaving the framework is a feature, not a defeat. The mistake would be doing the same for the CRUD screens, where it is genuinely shorter.
Filters and route groups replace the eleven if statements
The permission check leaves the controllers entirely and becomes a filter attached to a group of routes.
Route::filter('role', function ($route, $request, $roles) {
if (! Auth::check() || ! in_array(Auth::user()->role, explode(',', $roles))) {
return Redirect::guest('login');
}
});
Route::group(array('prefix' => 'stock', 'before' => 'auth|role:admin,buyer'), function () {
Route::resource('suppliers', 'SuppliersController');
Route::get('report', 'StockReportController@index');
});
The rule is now in one place and, more usefully, declared beside the routes it protects rather than buried inside the method it protects. A route added inside the group is covered by default, which is the inversion that matters: forgetting to opt in is the common mistake, forgetting to opt out is rare and loud.
The narrower rule — buyers may not edit cost prices — did not become a filter. It is a field-level constraint, it belongs with validation, and treating it as a routing concern would have been the sort of tidiness that costs you a year later.
Verifying it worked
The bar for rebuilding a screen that already exists is not that it is nicer. It is that it shows the same numbers, and there is a cheap way to check: run both panels against the same database and compare the pages.
$ php artisan migrate --seed --env=testing
$ vendor/bin/phpunit
PHPUnit 3.7.28 by Sebastian Bergmann.
OK (23 tests, 61 assertions)The suite exists only because the schema is now in the repository. The testing environment points at an SQLite file the suite creates and drops, so the tests build their own database from the migrations. The old panel has no tests — not because nobody wanted them, but because there was no way to put a known database in front of one.
The report was then compared row by row against the old one over three months of data. Two rows differed, both because the old query counted deactivated suppliers, which nobody had noticed and which had been quietly rounding the total down since 2011.
What this costs
The vendor directory is 40 MB across 39 packages, none of which I have read. That is ordinary with Composer by now, but different when the framework itself is the dependency: a framework upgrade is not optional the way a library upgrade is.
More to the point, something I have used for four months is now load-bearing for nine people’s daily work. I do not know how 4.0 behaves on the days it goes wrong: what its error messages mean at three in the morning, or how far the next version will move things. Four months is enough for an opinion about the ergonomics and nowhere near enough for one about the maintenance.
So the conclusion is deliberately narrow. Migrations and route filters removed two irritations that had been there for years, and I would not go back to the old arrangement for this panel. That is not a reason to move the main application, which is larger, customer-facing and running fine. The honest position at the end of September is that this was worth doing and worth stopping.