Skip to Content
Menu
All Posts

Article

Laravel Octane: With Great Performance Comes Great Responsibility

In 2021, Taylor Otwell made an announcement to the Laravel community that he was working on a new package, which he initially called Laravel Nitro - he should have stuck with that name 💀. Octane w...

Toluwani Adewale 18 min read
Updated

In 2021, Taylor Otwell made an announcement to the Laravel community that he was working on a new package, which he initially called Laravel Nitro - he should have stuck with that name 💀. Octane which eventually became the official name doesn't do justice to the wonders that it does to your Laravel project. 

The @CraftCMS team has let me know their local development environment for Craft is called Nitro (https://t.co/JwsAwRafyp) ... I will rename this package to something else if it is released! ❤️ https://t.co/JqPGAW36LL

— taylor otwell 🪐 (@taylorotwell) March 11, 2021

As you may already know, PHP is a modern general-purpose programming language and is one of the most suitable for building the web. It is sub-classified under Scripting Languages because it is an Interpreted Language. An Interpreted Language is in simple terms any language that allows the conversion of your code to machine-readable code (in the background) while your program runs. In other words, a Virtual Machine converts (interprets) your code into stuff that the computer can run and then runs it in one process.

A very simple and personified representation of this is an evangelist trying to get a message out to some locals in a different country. For every complete sentence that they make, a local interpreter gets the message out to the people almost at the same time depending on the size of the message. This process of course would be slower than if the local interpreter already had the interpreted message compiled and just had to read it to their fellow locals or even better; the locals read it themselves.

In small websites like this blog, the slower speed is relatively negligible because not many operations i.e. interpretations are being performed consecutively, and because the operations are also small in size, so are the interpretations. As functionality and usage increase in size, it becomes pertinent to improve performance. In many programming languages including PHP, this is done using the JIT (Just In Time) compiler/interpreter depending on the language's category of use. 
In straightforward terms, the JIT caches (synthetically compiles) the most used parts of the code. For example, the local interpreter no longer needs to directly interpret the phrase "the Word of God!" because the evangelist frequently uses it, thereby reducing the total time taken to interpret the message.

 Note:  These are all quite quick and simplified examples to get to the main point of this article much faster, kindly bear with me 💀.

In larger applications that rely on speed, the current speed might still not be sufficient enough. Optimized Algorithms and Data Structures, Data and Database Optimizations, Application-Wide Caching, and Queuing are some examples of optimization techniques that facilitate performance for the language.

In PHP, the code interpretation process is reinitiated on every request regardless of the applied technique. Once a request hits the server, the PHP script is converted into machine code. Suppose the user refreshes the page containing the script; the process is repeated all over again - take for JIT which we've discussed earlier and other performance techniques (another example is Opcache) that make the process faster in consecutive requests.

Laravel capitalizes on this process - and adds up some extra 💀; I've written a simple breakdown of Laravel's process below, it's called the Request Lifecycle

  1. A user requests your server, and the request is directed to the index/entry point which is index.php. This single file loads up Composer's generated autoload definitions which will allow your application to make use of specific installed package(s) necessary only for that request. This simply means that your entire vendor library isn't loaded during requests 🚀.
  2. An instance of your application is then created; visualize this summarily as Laravel helping you to mold all your many classes with its many classes, with other many classes from vendor libraries into a simple organized box of classes all with minimal configuration. This box then provides you with access to a lot of magic; stuff like automatic and manual class injections, and bindings, amongst others. An example of this magic is the ability to inject the Request class in any method within the Controller class and automatically get the request instance!
  3. The request is now sent to the Kernel which is split into HTTP and Console Kernel. The HTTP Kernel handles requests from the web, requests such as APIs. The Console Kernel on the other hand handles requests from the terminal. Any php artisan ... or Illuminate\Support\Facades\Artisan::... requests are handled here. 
    After deciding which task to handle, the Kernel then runs another configuration array of classes called the $bootstrappers. These classes include:
    • \Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class: This class detects the application environment i.e. the value of APP_ENV  in the application's .env file and creates/loads an instance of the file based on that value. 
    • \Illuminate\Foundation\Bootstrap\LoadConfiguration::class: This class loads up the array of configurations within all the files in config/ into a Repository. A repository put simply is a storage for data. Within this repository, the configurations are stored in a key-value pair.
    • \Illuminate\Foundation\Bootstrap\HandleExceptions::class: This class configures error handling, exception handling, and the processes that occur when the request lifecycle ends. 
    • \Illuminate\Foundation\Bootstrap\RegisterFacades::class: This class registers all the facades in the application instance. A facade is a type of abstraction that provides a static interface to underlying classes in the box (application instance). Simply put, they offer an easy-to-use and well-defined class that allows you to access public methods of the underlying class without having to instantiate the class.
    • \Illuminate\Foundation\Bootstrap\RegisterProviders::class: This class registers all the service providers in the application instance. A service provider binds various services to the box, these services could be Laravel's services such as Authentication and Database services and/or vendor services from installed packages. You can also create your own service/service provider. You'll find a list of them in an array called providers in config/app.php.
      Here, we'll concentrate a bit on a service provider called App\Providers\RouteServiceProvider::class. This class registers all our routes from their various route files e.g. web.php, api.php, and any other custom route file created in the box. After the box is booted, the request is matched - based on the route, first to the middleware where the request is sieved and then to the appropriate controller based on the logic in your route file.
    • \Illuminate\Foundation\Bootstrap\BootProviders::class: This class boots up the box. After all the molding, loading, registering, and binding performed by all the other bootstrappers, this one initializes them all in our application instance.
  4. A response is generated by the controller for the request and sent back to the middleware where it is sieved once again. The role of the middleware is to serve as a guard between the request and your application's logic. It is an extra layer of logic that can perform any action before the request reaches your controller.
    The response is then sent to the client i.e. the user that requested your server.

Although these processes are pretty fast and also get cached, they add "a little bit of slowdown" to your already "a little bit slow" 💀. This slow though is fast enough for ~85% of web applications, but as we've discussed previously, we always aim for faster and more performant applications and this is where Octane comes in.

 Tip:  Follow the documentation here to install Octane.

 

The first thing Octane does differently is the use of very high-performant PHP-specific servers. Using these servers, Octane is able to serve your application and save that instance once in memory. This means that the traditional way of reinitiating the code interpretation process as we've discussed previously is ditched, and there is only ever one full interpretation (synthetic compilation) in which the requests from your user feed off.
We now have the power of servers that are built specifically for increasing the performance of PHP applications also with a much faster "compilation process", but these come with a couple of tradeoffs:

  1. Your file changes will no longer be reflected; any additions/subtractions that you make during the development of your application will not be reflected when you refresh the page as it would have traditionally. To handle this case, you can install a watcher as described in the docs.
  2. Your Statics! Static objects create a very tight coupling with classes. It's basically one copy of that object that can be used in every instance of the class, any change to the state (value) of that object leads to the same change wherever the object is used. With Octane, this can lead to the following:
    • Memory Leaks: Consider the example below (these are all within a file called OctaneController, you can create a similar controller and route to test).
      <?php
      
      namespace App\Http\Controllers;
      
      use Illuminate\Support\Str;
      
      class MyMemoryLeaker
      {
        // Static array object 
        public static array $staticAccumulate = [];
      
        // Non-static array object
        public array $accumulate = [];
      
        // Getter for the static array object 
        public static function getStaticAccumulate()
        {
          return self::$staticAccumulate;
        }
      
        // Getter for the non-static array object
        public function getAccumulate()
        {
          return $this->accumulate;
        }
      }
      
      ...

       

      ...
      
      class OctaneController extends Controller
      {
       public function __invoke()
        {
          $startTime = microtime(true);
      
          // Creates 1MB of string data (a)
          MyMemoryLeaker::$staticAccumulate[] = Str::repeat('a', 1024 * 1024);
      
          $endTime = microtime(true);
          // Measures time (in seconds)
          $totalTime = $endTime - $startTime;
          // Measures memory (in MB)
          $memoryUsage = memory_get_peak_usage(true) / 1024 / 1024;
      
          // Although the total time taken is largely unaffected, the memory increases with every request to the controller
          ray([$totalTime, $memoryUsage]);
        }
      }
      
      ...

      The snippet above supposedly pushes 1MB of data into the array. As this is a static object, it can get unpredictable because whenever the user sends requests to the page - this controller for instance, more data accumulate in the array, and memory usage increases. This is also true for every instance of MyMemoryLeaker::$staticAccumulate[] i.e. anywhere - controller, actions, services, etc where data is added to that object, it adds up that data to an existing copy. This happens until there's no memory left and your server crashes, the page becomes unresponsive and your user is left frustrated.
      To handle this case, you can refactor using Dependency Injections, an example is shown below.

      ...
      
      class OctaneController extends Controller
      {
       public function __invoke(MyMemoryLeaker $myMemoryleaker)
        {
          $startTime = microtime(true);
      
          // Creates 1MB of string data (a)
          $myMemoryleaker->accumulate[] = Str::repeat('a', 1024 * 1024);
      
          $endTime = microtime(true);
          // Measures time (in seconds)
          $totalTime = $endTime - $startTime;
          // Measures memory (in MB)
          $memoryUsage = memory_get_peak_usage(true) / 1024 / 1024;
      
          // The memory stays the same
          ray([$totalTime, $memoryUsage]);
        }
      }
      
      ...

      Here we ditch the static keyword and make use of dependency injection to get an instance of the class, this is more predictable.

    • Singletons: Singletons are classes that are instantiated once and shared throughout the application. Think of them simply as the class version of the static object that we discussed above 💀. It is advised to ditch them.
      Laravel is also a culprit in the use of singletons 💀, they do have their advantages, especially in synchronous models but can be a pain going forward. Take the example below in app/Providers/AppServiceProvider.php
      <?php
      
      use Illuminate\Contracts\Foundation\Application;
      use Stripe\StripeClient;
      
      class AppServiceProvider extends ServiceProvider
      {
        /**
         * Register any application services.
         *
         * @return void
         */
        public function register()
        {
          app()->singleton(StripeClient::class, function (Application $app) {
            return new StripeClient($app);
          });
        }
      
      ...

      From what we've discussed about Laravel's Request Lifecycle, an instance of the Application is created, and this is done using Singletons along with Containers (lol we didn't discuss this one) and other Service Providers. Fortunately, Octane handles Laravel's other Singletons for you for every new request but really doesn't know how to handle the Application's Singleton, it simply makes sense to not tamper with the stored application instance in memory.
      From the snippet above, if Stripe's secrets in the application's .env file are modified, chances are that they might not get reflected as an older instance of the application is in use. Also, the StripeClient::class is further instantiated as a singleton and this is simply dangerous. To handle this case, you can refactor using bind in place of singleton and/or resolve only the current instance of the application as shown below.

      <?php
      
      namespace App\Providers;
      
      use Illuminate\Container\Container;
      use Illuminate\Contracts\Foundation\Application;
      use Stripe\StripeClient;
      
      class AppServiceProvider extends ServiceProvider
      {
        /**
         * Register any application services.
         *
         * @return void
         */
        public function register()
        {
          app()->bind(StripeClient::class, function (Application $app) {
            return new StripeClient($app);
          });
      
          // Or if you still would prefer singletons
          app()->singleton(StripeClient::class, function () {
            return new StripeClient(fn() => Container::getInstance());
          });
        }
      
      ...

 Tip:  Although facades provide static interfaces to underlying classes, they can be safely used with Octane, including custom facades.

 

 Note:  You will require Swoole Server for the following features. I recommend Swoole.

The second thing Octane does differently is the use of concurrent models, a concept that isn't custom to the language itself. PHP by default being a single-threaded, synchronous language will only allow one request-to-task at a time. Using our evangelist example, the interpreter is never allowed to do any other task other than interpreting a single complete sentence at a time. Even if the evangelist were to continue adding up more sentences while interpreting a previous one, the interpreter isn't allowed to interpret that until they've successfully interpreted the previous sentence. The sentences keep queuing up, and the interpreter selects from that queue sequentially, one at a time.
In the sense of web applications, this synchronous model makes a lot of sense majorly because just like the HTTP Protocol that powers the web, it is stateless; each request is independent and does not really care much about other incoming requests. It is simple, predictable, and easy to correct.
On the other hand, in process-intensive web applications, this model will flop. This is because a single process can take up much time to interpret thereby delaying other processes.

Take a look at the following example:

<?php

namespace App\Http\Controllers;

use Laravel\Octane\Facades\Octane;

class OctaneController extends Controller
{
  public function __invoke()
  {
    $startTime = microtime(true);

    // Operation 1
    sleep(5);
    // Operation 2
    sleep(2);
    // Operation 3
    sleep(2);

    $endTime = microtime(true);

    $totalTime = $endTime - $startTime;

    ray($totalTime); // 9.**
  }
}

The output of $totalTime in this case, will be ~9 seconds i.e. 5 + 2 + 2 as the time taken to complete these operations. Also, each operation is performed sequentially as Operation 2 starts immediately after Operation 1 is completed ditto for Operation 3. Take a look at Octane's process below:

<?php

namespace App\Http\Controllers;

use Laravel\Octane\Facades\Octane;

class OctaneController extends Controller
{
  public function __invoke()
  {
    $startTime = microtime(true);

    Octane::concurrently([
      // Operation 1
      fn() => sleep(5),
      // Operation 2
      fn() => sleep(2),
      // Operation 3
      fn() => sleep(2),
    ]);

    $endTime = microtime(true);

    $totalTime = $endTime - $startTime;

    ray($totalTime); // 5.**
  }
}

The output of $totalTime in this case, will be ~5 seconds i.e. the highest time taken to process any operation inside Octane's concurrently method. All operations are performed using a concurrency model called Multiprocessing. Multiprocessing in simple terms is a technique of running different processes at the same time. This is achieved in the Swoole server by creating task-workers that handle each operation concurrently. Think of them as threads that handle processes, the more the task worker that you set, the more operations that can be handled. 

<?php

namespace App\Http\Controllers;

use Laravel\Octane\Facades\Octane;

class OctaneController extends Controller
{
  public function __invoke()
  {
    $startTime = microtime(true);

    Octane::concurrently([
      fn() => sleep(2),
      fn() => sleep(2),
      fn() => sleep(2),
      fn() => sleep(2),
      fn() => sleep(2),
      fn() => sleep(2),
      fn() => sleep(2),
      fn() => sleep(2),
      fn() => sleep(2),
    ]);

    $endTime = microtime(true);

    $totalTime = $endTime - $startTime;

    // if --task-workers=6
    // Why do you think we get 4?
    ray($totalTime); // 4.**
  }
}

We have 9 processes for Octane with 6 task workers specified, each of these workers takes 1 process so we're left with 3 processes, the time taken for this operation is still ~2. Another 3 workers each take in an extra process i.e. fn() => sleep(2) twice, which makes the highest time taken to process; 4 seconds.

Concurrency, in this case multiprocessing is great as we've noted, but special care should be taken as we're now dealing with a subtle deal of complexity.

<?php

namespace App\Http\Controllers;

use Exception;
use Laravel\Octane\Facades\Octane;

class OctaneController extends Controller
{
  public function __invoke()
  {
    $startTime = microtime(true);

    Octane::concurrently([
      fn() => ray(1),
      fn() => sleep(2),
      fn() => throw new Exception('test'),
      fn() => sleep(2),
      fn() => ray(2),
      fn() => sleep(2),
      fn() => ray(3),
      fn() => sleep(2),
      fn() => ray(4),
    ]);

    // Unreachable code!
    // The processes below are never executed as the exception will be thrown after concurrency
    $endTime = microtime(true);

    $totalTime = $endTime - $startTime;

    ray($totalTime);
  }
}
  1. The order in which each process is executed is unprecedented. In some cases, you get:
    • 1, 2, 3, Exception, 4. 
    • 2, 1, Exception, 3, 4.
    • 2, 1, 3, Exception, 4.
  2. This shouldn't necessarily replace the use of Queues to handle long-timing processes in the background as the processes are not retried/rerun, also the total time of completion is still equivalent to the process with the highest time of completion.
  3. Although whenever a process fails within the concurrency during execution, it does not affect the other process(es) since each process is handled by a different worker, even failed processes handled by a worker do not affect the execution of any other process handled by that worker. It is still important to note that if there is such a failure, it affects the execution of processes outside the concurrency, and the exception(s) are thrown. This is necessary to note for exception handling.
  4. Out of the box, multiprocessing is not true statefulness as each process has its own unshared state - data and operations (processes). A simple example of creating a user model and in turn using it to create a profile model will not work and shouldn't be attempted for data integrity.
  5. Special care should also be taken for memory as the more operations performed, the more memory usage.

 Tip:  Speaking of memory, you can actually force Octane to collect garbage when your application reaches a certain threshold in memory consumption. You can do this by uncommenting  CollectGarbage::class and setting 'garbage' => {memory threshold in MB} in config/octane.php. As garbage collection is a much wider story for another day, I'd advise to not use this and manage memory efficiently instead.

 

The third thing Octane does differently is the use of special cache models. We already know how performant caches make our application, and now using Swoole Tables, Octane is able to provide more high-performant models that can help us store or retrieve data ~2 million times in one second.

  1. Octane Cache: This implements Swoole Tables as a driver for Laravel's default cache system.
    <?php
    
    namespace App\Http\Controllers;
    
    use Illuminate\Support\Facades\Cache;
    use Illuminate\Support\Str;
    
    class OctaneController extends Controller
    {
      public function __invoke()
      {
        Cache::store('octane')->put('a', Str::repeat('a', 10), 30);
        $a = Cache::store('octane')->get('a');
    
        ray($a);    
      }
    }
  2. Octane Table: This implements Swoole Tables as a store for data. Data is stored here in rows and columns as you would in a database table - rows and columns. To start using this you must first set up tables (like migrations) in config/octane.php like shown below:
    <?php
    
        ...
    
        'tables' => [
            'users:1000' => [
                'fullname' => 'string:1000',
                'role' => 'string:20',
                'likes' => 'int',
                'created_at' => 'int',
            ],
            'teams:1000' => [
                'name' => 'string:1000',
                'created_at' => 'int',
            ],
        ],
    
        ...
    }

    For the table i.e. users:1000, 1000 represents the maximum number of rows that the table can accommodate. 
    For the rows i.e string:n, 1000 and 20 represent the maximum column size (number of bytes) that the rows can accommodate. This setting also applies to Octane Cache. For instance, you cannot set 1 MB of string in the cache as the maximum is 10000.
    You can increase this in the cache array in config/octane.php.

    <?php
    
    namespace App\Http\Controllers;
    
    use Laravel\Octane\Facades\Octane;
    
    class OctaneController extends Controller
    {
      public function __invoke()
      {
        Octane::table('users')->set('admin', [
            'fullname' => 'Toluwani Adewale',
            'role' => 'admin',
            'likes' => 0,
            'created_at' => now()->timestamp,
        ]);
    
        Octane::table('users')->set('user', [
            'fullname' => 'Jane Doe',
            'role' => 'user',
            'likes' => 0,
            'created_at' => now()->timestamp,
        ]);
    
        $admin = Octane::table('users')->get('admin');
        $user = Octane::table('users')->get('user');
    
         ray([
             'admin' => $admin,
             'user' => $user,
         ]);
      }
    }

    There are a couple of caveats to consider when using Octane Tables:

    1. They are both temporary stores of data and data will be lost when the server is reloaded - e.g. changing a file if you've configured watcher 💀. Octane cache should replace Laravel's default cache only when necessary.
    2. Octane Table's current types are limited to string, int, and float. It is an in-memory temporary data structure and should never be used as a database.
    3. Once again, the more data stored in these, the more memory is used.

     

 Tip:  I strongly advise that you make use of Laravel Sail when setting up Octane. It simplifies setup and configuration. 

 

Conclusion

Octane is truly a game changer for Laravel applications and as it becomes more mainstream in the community, we'll discover more awesome usage and caveats. Let me know if you've identified any in the comments.

Responses (2)

Have a question, correction, or another approach? Add your response.

Your name and response will be public.

andu

Very interesting!

Simon

Excellent writeup on Octane!