Moving to a SaaS model with Symfony
For the past few months Browser has been working hard on the development of an internal intranet and HR product. When the application was first conceived it was designed as a bespoke, standalone tool to enable our organisation to manage its staff. After a while, we discovered that it had the potential to serve other key areas of HR, and so over the past year the app has been refactored to become the modular based SaaS model HR product we have today.
Fragmented codebase
The original codebase had been modified and deployed separately for many of our clients and we’re reaching a point where we needed to standardise the codebase into one version. This coincided with a gap in our development schedules so we decided to bite the bullet and rebuild the platform from the ground up to suit the new modular-based structure.
Moving to a SaaS model means that we can deploy new features that benefit all of our clients equally on a single instance of the software. This, in turn, results in a much simpler workflow that does not involve back-porting features to multiple copies of the app, allowing us to adapt to and serve client needs with new features more quickly and easily than before.
The rebuild of our SaaS model using Symfony
During planning we defined some key requirements:
- Redesign the user experience, simplify, declutter and encourage engagement.
- Start from a clean framework, port over existing features one by one.
- Provide support for turning features on and off on a per-tenant basis.
- Run a single codebase and move to a SaaS model.
That final point was the biggest challenge – the need to rebuild the software to run from one well-managed codebase. With the security and integrity of our client’s data a primary concern we quickly realised that we needed to find a way to segment clients from each other while enabling these different feature sets upon demand.
On a large application such as this, we make a lot of complex queries to our database – none of these queries were set up with the segregation of user data in mind. We quickly found a solution that would allow us to both retain our existing database structures, but also maintain separation while using a single database.
Doctrine Filters and Listeners
Doctrine filters allow us to intercept any DQL SELECT query and modify it before it is dispatched to the database. This means that we can retain our existing queries but add an additional WHERE clause to ensure that that the data returned will never belong to a different client.
Where before we may have had a simple query such as:
SELECT * FROM AccountBundle:Person WHERE `status` = 'active';
Our doctrine filter will intercept this and change it to:
SELECT * FROM AccountBundle:Person WHERE `status` = 'active' AND `tenant_id` = 1234;
The upshot is that we are not required to audit all of our code to ensure that this important separation is maintained. This will work for subqueries, joins and all manner of complex queries too, it also acts to ensure that we have a tenant_id allocated against every single piece of data for absolute traceability of ownership.
The opposite is also true. While we don’t have the support of the Doctrine Filters we can replicate the same effect using a Symfony Event Listener. This is fairly straightforward – we listen to all DQL transactions and modify them to insert the tenant id when a row is created.
Exceptions to the rule
While Doctrine allows us to enforce this separation when it comes to some key elements of the software stack we need to be a little more creative.
Native SQL queries are not picked up by the filtering process and need to be altered manually. Thankfully in this app, these are very few and far between and support mostly internal reporting functions. Our automated testing regime is specifically set up to identify tenanting issues on all features regardless of how the database queries are performed.
Our search platform, Elasticsearch, also has no inbuilt support for tenanting. Our solution to this is to ensure that all entities populated into the index include the relevant Tenant ID. We are then able to wrap our search code in a bundle that always adds the current user’s Tenant ID to all queries.
As a result, we have managed to build a resilient system that clearly separates data between tenants without adding a significant burden to our development schedule.