Why Skinny
Slim v2.6.3 => Skinny
Why depart from an active, well-written PHP framework to do your own thing? The short answer: because you can. Sometimes you just want the code to do what you want the code to do.
Going your own way has a downtside: you take on the responibility of maintaining and advancing the framework. The propsect wasn't too daunting with Slim, since it was small — only 21 code files. Still, as the gap widens it becomes more difficult to incorporate changes from the original project. Tracking the original project wasn't an issue with Slim v2, since Slim v3 departed significantly from v2 anyway.
Working with Slim v2
Skinny resulted from the experience of a team that builds and maintains a SaaS application used by Fortune 1000 companies. The application was started a decade earlier and was showing its age. Like many legacy apps, it began as a simple, in-house utility. It ”evolved” as additional needs were identified and wired onto it. Along with this ad hoc evolution, there was no concept of seperation of concerns. An overhaul was overdue — a complete refactoring in line with MVC best practices.
Using a PHP framework to facilitate this task was a given. But, which one? Intially, Laravel was chosen for the project. Early on, however, it became apparent Laravel was too ”opinionated” to be useful in the team's side-by-side, incremental transformation of legacy code into its new, more elegant and extensible design. After scaling back expectations of the role of a PHP framework, several smaller frameworks were considered. Slim was eventually chosen becuase it was small, actvely maintained, had no third party dependencies, and — most importantly — did what it was supposed to do and then stayed out of the way.
Where Slim Fell Short
A number of shortcomings became apparent as the refactoring project progressed.
- Poor scaling of routes
- Always-on output buffering
- No control over unwanted middleware
- Incomplete app instance tracking
- Inability to destroy an app instance
- Inflexible handling of php://input stream
What follows are explanations of how Skinny addresses each of these limitations.
Making Routes Scalable
Slim v2, Legacy Approach
Slim populates an array of Route instances for every route definition, even for routes within multiple levels of nested route groups whether the requsted URI matches the group's pattern or not. Regular expression evaluation is performed on all routes that support the current request method.
This approach may be fine for a small application, but is doesn't scale well for an application with hundreds of routes. Of course, you could wrap route group definitions in conditionals to restrict the size of the routes array, but that gets ugly very quickly.
Slim's routing has another potential resource gobbler: every class is loaded where a route callable is defined using string (e.g. 'class::method') or array (e.g. ['class', 'method']) notatoin. The only way to avoid this massive loading of classes is to use anonymous functions for callables instead of the cleaner notation. Even then, the anonymous functions are stored with the Route instance in the routes array.
Slim allows a custom callable notation (e.g. 'class:method') for class instance methods. It doesn't load the class, because it converts it into an anonymous function — but like other lamda function callables, it gets stored on the routes array along with the Route instance, even if it doesn't need to be invoked.
Skinny still retains Slim's legacy routing behavior, in case that's what you want. Legacy route definition methods have an ”rt” prefix (e.g. rtGet(), rtPost(), and so on). The corresponding methods without the prefix have Skinny's new behavior. New methods and legacy methods can be used separately or mixed to define routes.
Skinny's Solution
In the end, only one matching route will be dispatched. The new approach short-circuits the search after the first matched route. It also does a sanity check on route groups to skip those that have no chance of containing a matching route. After a match is found, all subsequent routes and groups are effectively ignored.
The routes array only gets a single member no matter how many routes are defined. Regex is performed only on routes that support the current request method, and then only until a matching route is found. This approach also limits the number classes that get loaded during the search for the first matching route.
Route group evaluations are fast and cheap, because they use substr() instead of preg_match(). Properly structured, even a massive application with hundreds of routes can limit the number of regex evaluations to a tiny fraction of the total number of defined routes.
Comparison of Routing Behavior
The demo app displays two side-by-side, interactive route heirachries: one defined with the new behavior; the other defined with legacy methods. Here's a rundown on the differences:
| Characteristic | Legacy (Slim v2) | Skinny |
|---|---|---|
| Scalability | Poor: memory requirements (routes array) and CPU resources (regex tests) increase in direct proportion to the number of routes defined | Good: a well-designed route hierarchy can limit the number of route REQUEST_METHOD checks, as well as the number of regex tests, to a tiny fraction of the total number of routes defined, even in a very large application |
| After Matched Route | Testing continues on all remaing routes | Flag is set to short-circuit all subsequent routes and groups with no further testing |
| Routes Array Size | An element for every defined route | Only one; the matching route |
| Request Method Check | All routes, even after a match is found | Only routes encountered — up to and including the first matching route |
| Regular Expression Test | All routes supporting REQUEST_METHOD | Only routes encountered — up to and including the first matching route — that support REQUEST_METHOD |
| Classes Loaded | All route callables that aren't lamda functions | Only classes in route callables up to and including the first matching route |
| Route Groups | No effect on array size or number of regex tests | Well-designed, nested groups can effectively funnel routing to a matching route with the fewest number of intervening tests, even in very large applications |
| Position in Route Hierarchy | No effect | Placement in hierarchy can favor some routes over others. Prudent application designers, who recognize the benefit of this feature, will make more-frequently-used routes the quickest to match. |
Controlling Output Buffering
Output buffering is always enabled in Slim v2. This behavior is problematic for serving large documents for download or streaming a report to a user as it's being generated. (Slim v3 addresses this limitation, as well.) Skinny provides a boolean 'output.buffered' configuration setting for this purpose.
Configuring Middleware
In Slim v2, Flash and MethodOverride middleware are always enabled. So is PrettyExceptions if the 'debug' setting is true. Skinny provides 'middleware.flash' and 'middleware.prettyExceptions' configuration settings for control over what application middleware is added to the stack. Skinny went the opposite direction with MethodOverride by embedding it in the core framework class and eliminating it as middleware.
Tracking Application Instances
Legacy, Slim v2 Behavior
Assigning a name to an application instance is optional in Slim v2, even though it implicily names the first instance 'default'. For subsequent instances to be tracked in the static $app array, you must explicitly call setName($name) to add it. In a real-world app, this behavior works well eough, since multiple app instances are seldom needed. Not so when testing with phpunit.
Unit tests running phpunit in non-isolation mode — the usual method — all tests run in the same process. During testing, an app instance can be created many times. Even when assigned to the same variable, the 'new' instance isn't new at all, because the previous instances' destructors aren't invoked, leaving attached objects, like singletons, to multiply like rabbits.
This issue because painfully obvious when a database class was assigned as a singleton. It worked as expected in the real app; in the test environment, database connectoins piled up until the connection limit was exceeded and halted everything. Attempts to close each connection were largely unsuccessful.
Skinny Tracks All Instances By Name
Skinny requires unique names for all application instances, enabling tracking of every application instance in the static $apps property.
Heads Up! Calling
newInstance()with the name of an existing instance will clear and reset it as if it were new.
// Instantiate application with defautt settings and assign the name 'default'
$app = \Skinny\Skinny::newInstance();
// Assign settings and an instance name
$api = \Skinny\Skinny::newInstance(['debug' => false], 'api');
// Get reference to instance in another scope
class A
{
public function someMethod()
{
$app = \Skinny\Skinny::getInstance(); // instance named 'default'
$api = \Skinny\Skinny::getInstance('api');
}
}
The read-only static methods, getInstanceCount() and getInstanceNames(), provide information about tracked instances.
Resetting/claring of a re-used named instance went a long way toward taming unexpected side effects some unit tests were having on other tests.
Destroying Instances
Tracking all application instances was only part of the solution for the unit test issue. There needed to be a way to destroy app instances. One instance method and two static methods were added for this purpose.
- public function unregisterThisInstance()
- public static function unregisterNamedInstance($instanceName)
- public static function unregisterAllInstances()
Note: In order for singleton objects to be released from an application instance, they must clean up any resources (e.g. database connections) in their own destructors.
When needed, these methods completely remove one or more application instances. The instance's destructor is executed, and the objects held by the instance are garbage collected.
PHP Input Stream Handling
Slim captures php://input into a variable as a single string using file_get_contents(). This approach provides no means to monitor, limit, or otherwise control a file upload via an AJAX initiated PUT stream. Skinny overcomes this limitation by providing an alternate input configuration option. It's documented here.