├── tests ├── .keep ├── source │ ├── helper.php │ ├── views │ │ ├── weight.blade.php │ │ └── nested.blade.php │ └── laravel │ │ ├── routes.php │ │ ├── autoload.php │ │ ├── database.php │ │ └── app.php └── AbTests.php ├── .gitignore ├── src ├── config │ └── config.php ├── App │ ├── Goal.php │ ├── Experiments.php │ ├── Events.php │ ├── Instance.php │ ├── Http │ │ └── Middleware │ │ │ └── LaravelAbMiddleware.php │ ├── Console │ │ └── Commands │ │ │ ├── AbMigrate.php │ │ │ ├── AbRollback.php │ │ │ └── AbReport.php │ └── Ab.php └── LaravelAbServiceProvider.php ├── .travis.yml ├── composer.json ├── travis.build.sh ├── LICENSE ├── migrations ├── 2015_08_15_000001_create_ab_tables.php └── 2016_10_12_000001_fix_ab_column_types.php └── README.md /tests/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | composer.lock 3 | vendor/ 4 | -------------------------------------------------------------------------------- /src/config/config.php: -------------------------------------------------------------------------------- 1 | 'laravel_ab_user', 5 | ]; -------------------------------------------------------------------------------- /tests/source/helper.php: -------------------------------------------------------------------------------- 1 | 'info']; 5 | } -------------------------------------------------------------------------------- /tests/source/views/weight.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | @ab('test-weight') 4 | @condition('option-1-1 [100]') 5 | YES-SEE-THIS 6 | @condition('option-1-2 [1]') 7 | DONT-SEE-THIS 8 | @track('test-weight-goal') 9 | 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.6 5 | - 5.5 6 | - 7 7 | - hhvm 8 | 9 | before_install: 10 | - bash travis.build.sh 11 | - cd build/laravel 12 | 13 | script: 14 | - phpunit ./vendor/comocode/laravel-ab/tests/AbTests.php 15 | 16 | after_script: 17 | - cd ../../ 18 | - rm -rf build 19 | -------------------------------------------------------------------------------- /tests/source/laravel/routes.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | @ab('test-1') 4 | @condition('option-1-1') 5 | Test-1-Option-1 6 | @condition('option-1-2') 7 | Test-1-Option-2 8 | @condition('option-1-3') 9 | @ab('test-2') 10 | @condition('option-2-1') 11 | Test-2-Option-1 12 | @condition('option-2-2') 13 | Test-2-Option-2 14 | @track('test-2-goal') 15 | @track('test-2-goal') 16 | 17 | -------------------------------------------------------------------------------- /src/App/Goal.php: -------------------------------------------------------------------------------- 1 | belongsTo('ComoCode\LaravelAb\App\Experiment'); 13 | } 14 | 15 | public function instance() 16 | { 17 | return $this->belongsTo('ComoCode\LaravelAb\App\Instance'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/App/Experiments.php: -------------------------------------------------------------------------------- 1 | hasMany('ComoCode\LaravelAb\App\Events'); 13 | } 14 | 15 | /*public function goals(){ 16 | return $this->hasMany('EightyTwoRules\LaravelAb\Goal', 'goal','goal'); 17 | }*/ 18 | } 19 | -------------------------------------------------------------------------------- /src/App/Events.php: -------------------------------------------------------------------------------- 1 | belongsTo('ComoCode\LaravelAb\App\Experiment'); 14 | } 15 | 16 | public function instance() 17 | { 18 | return $this->belongsTo('ComoCode\LaravelAb\App\Instance'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comocode/laravel-ab", 3 | "description": "Blade level AB tests for Laravel 5", 4 | "keywords": ["laravel", "ab", "ab tests"], 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Rulian Estivalletti", 10 | "email": "rulian.estivalletti@gmail.com" 11 | } 12 | ], 13 | "autoload": { 14 | "classmap" : [ 15 | "tests" 16 | ], 17 | "psr-4": { 18 | "ComoCode\\LaravelAb\\": "src" 19 | } 20 | }, 21 | "require": {} 22 | } 23 | -------------------------------------------------------------------------------- /src/App/Instance.php: -------------------------------------------------------------------------------- 1 | hasMany('ComoCode\LaravelAb\App\Events'); 13 | } 14 | 15 | public function goals() 16 | { 17 | return $this->hasMany('ComoCode\LaravelAb\App\Goal'); 18 | } 19 | 20 | public function setMetadataAttribute($value) 21 | { 22 | $this->attributes['metadata'] = is_null($value) ? null : serialize($value); 23 | } 24 | 25 | public function getMetadataAttribute($value) 26 | { 27 | return unserialize($value); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/App/Http/Middleware/LaravelAbMiddleware.php: -------------------------------------------------------------------------------- 1 | withCookie(cookie()->forever(config('laravel-ab.cache_key'), $cookie)); 24 | } 25 | 26 | return $response; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /travis.build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | composer self-update 4 | composer create-project laravel/laravel:5.2.31 --prefer-dist build/laravel 5 | mkdir -p ./build/laravel/vendor/comocode 6 | ln -s `pwd` ./build/laravel/vendor/comocode/laravel-ab 7 | cd ./build/laravel 8 | touch storage/database.sqlite 9 | cp ./vendor/comocode/laravel-ab/tests/source/laravel/app.php ./config/app.php 10 | cp ./vendor/comocode/laravel-ab/tests/source/laravel/database.php ./config/database.php 11 | cp ./vendor/comocode/laravel-ab/tests/source/laravel/routes.php ./app/Http/routes.php 12 | sed -e 's/"app\/"/"app\/","ComoCode\\\\LaravelAb\\\\": "vendor\/comocode\/laravel-ab\/src"/' composer.json > temp.json && mv temp.json composer.json 13 | sed -e s/\)\;/\)/g ./vendor/composer/autoload_classmap.php > temp.php && mv temp.php ./vendor/composer/autoload_classmap.php 14 | cat ./vendor/comocode/laravel-ab/tests/source/laravel/autoload.php >> ./vendor/composer/autoload_classmap.php 15 | composer dump-autoload 16 | php artisan ab:migrate --force 17 | nohup php artisan serve & -------------------------------------------------------------------------------- /src/App/Console/Commands/AbMigrate.php: -------------------------------------------------------------------------------- 1 | call('migrate', [ 35 | '--path' => 'vendor/comocode/laravel-ab/migrations/', 36 | '--force' => $this->hasOption('force') ? true : false, 37 | ]); 38 | 39 | $this->info('AB tables created successfully'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Como Code 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /tests/source/laravel/autoload.php: -------------------------------------------------------------------------------- 1 | + array( 2 | 'ComoCode\\LaravelAb\\App\\Ab' => $vendorDir . '/comocode/laravel-ab/src/App/Ab.php', 3 | 'ComoCode\\LaravelAb\\App\\Console\\Commands\\AbMigrate' => $vendorDir . '/comocode/laravel-ab/src/App/Console/Commands/AbMigrate.php', 4 | 'ComoCode\\LaravelAb\\App\\Console\\Commands\\AbReport' => $vendorDir . '/comocode/laravel-ab/src/App/Console/Commands/AbReport.php', 5 | 'ComoCode\\LaravelAb\\App\\Console\\Commands\\AbRollback' => $vendorDir . '/comocode/laravel-ab/src/App/Console/Commands/AbRollback.php', 6 | 'ComoCode\\LaravelAb\\App\\Events' => $vendorDir . '/comocode/laravel-ab/src/App/Events.php', 7 | 'ComoCode\\LaravelAb\\App\\Experiments' => $vendorDir . '/comocode/laravel-ab/src/App/Experiments.php', 8 | 'ComoCode\\LaravelAb\\App\\Goal' => $vendorDir . '/comocode/laravel-ab/src/App/Goal.php', 9 | 'ComoCode\\LaravelAb\\App\\Http\\Middleware\\LaravelAbMiddleware' => $vendorDir . '/comocode/laravel-ab/src/App/Http/Middleware/LaravelAbMiddleware.php', 10 | 'ComoCode\\LaravelAb\\App\\Instance' => $vendorDir . '/comocode/laravel-ab/src/App/Instance.php', 11 | 'ComoCode\\LaravelAb\\LaravelAbServiceProvider' => $vendorDir . '/comocode/laravel-ab/src/LaravelAbServiceProvider.php', 12 | ); -------------------------------------------------------------------------------- /src/App/Console/Commands/AbRollback.php: -------------------------------------------------------------------------------- 1 | confirm('Do you wish to continue? [y|N]')) { 44 | 45 | // TODO: create a more efficient way to handle down migrations 46 | 47 | $fix_migration = new \FixAbColumnTypes(); 48 | if ($fix_migration->down()) { 49 | $this->info("Column types reverted successfully"); 50 | } else { 51 | $this->error("Could not revert column types"); 52 | } 53 | 54 | $create_migration = new \CreateAbTables(); 55 | if ($create_migration->down()) { 56 | $this->info("AB tables destroyed successfully"); 57 | } else { 58 | $this->error("Could not delete AB tables"); 59 | } 60 | } else { 61 | $this->error("user exited, nothing done"); 62 | } 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /migrations/2015_08_15_000001_create_ab_tables.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('experiment'); 19 | $table->string('goal')->nullable(); 20 | $table->timestamps(); 21 | }); 22 | 23 | Schema::create('ab_events', function(Blueprint $table) 24 | { 25 | $table->increments('id'); 26 | $table->string('instance_id')->nullable(); 27 | $table->string('experiments_id')->nullable(); 28 | $table->string('name'); 29 | $table->string('value'); 30 | $table->timestamps(); 31 | }); 32 | 33 | Schema::create('ab_instance', function(Blueprint $table) 34 | { 35 | $table->increments('id'); 36 | $table->string('instance'); 37 | $table->string('identifier'); 38 | $table->string('metadata')->nullable(); 39 | $table->timestamps(); 40 | }); 41 | 42 | Schema::create('ab_goal', function(Blueprint $table) 43 | { 44 | $table->increments('id'); 45 | $table->string('instance_id')->nullable(); 46 | $table->string('goal'); 47 | $table->string('value')->nullable(); 48 | $table->timestamps(); 49 | }); 50 | } 51 | 52 | 53 | /** 54 | * Reverse the migrations. 55 | * 56 | * @return boolean 57 | */ 58 | public function down() 59 | { 60 | Schema::drop('ab_experiments'); 61 | Schema::drop('ab_events'); 62 | Schema::drop('ab_instance'); 63 | Schema::drop('ab_goal'); 64 | 65 | \DB::connection()->getPdo()->exec("delete from migrations where migration = '2015_08_15_000001_create_ab_tables'"); 66 | return true; 67 | } 68 | 69 | } 70 | 71 | -------------------------------------------------------------------------------- /migrations/2016_10_12_000001_fix_ab_column_types.php: -------------------------------------------------------------------------------- 1 | getPdo()->exec("delete from migrations where migration = '2016_10_12_000001_fix_ab_column_types'"); 49 | 50 | return true; 51 | } 52 | 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /tests/AbTests.php: -------------------------------------------------------------------------------- 1 | make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); 29 | 30 | return $app; 31 | } 32 | 33 | public function testDefaultCreation(){ 34 | 35 | $ab = app()->make('Ab'); 36 | $instance = $ab->experiment('Test'); 37 | $instance->condition('one'); 38 | echo "condition 1"; 39 | $instance->condition('two'); 40 | echo "condition 2"; 41 | $instance->track('goal'); 42 | $ab->goal('goal'); 43 | 44 | Ab::saveSession(); 45 | 46 | 47 | $experiments = Experiments::where(['experiment'=>'Test'])->get(); 48 | 49 | $goals = Goal::where(['goal'=>'goal'])->get(); 50 | 51 | $experiment = $experiments->first(); 52 | 53 | $this->assertEquals($experiments->count(),1); 54 | $this->assertEquals($experiment->events()->count(), 1); 55 | 56 | $this->assertEquals($goals->count(),1); 57 | 58 | } 59 | 60 | public function testNestedView() 61 | { 62 | $this->visit('/') 63 | ->see('Test-') 64 | ->dontSee('@ab'); 65 | 66 | Ab::saveSession(); 67 | 68 | $test1_experiments = Experiments::where(['experiment'=>'test1'])->get(); 69 | $test2_experiments = Experiments::where(['experiment'=>'test2'])->get(); 70 | 71 | $test1 = $test1_experiments->first(); 72 | $test2 = $test2_experiments->first(); 73 | 74 | $this->assertEquals($test1_experiments->count(), 1); 75 | $this->assertEquals($test1->events()->count(), 1); 76 | 77 | $this->assertEquals($test2_experiments->count(), 1); 78 | $this->assertEquals($test2->events()->count(), 1); 79 | 80 | 81 | 82 | } 83 | 84 | 85 | public function testWeighted() 86 | { 87 | $this->visit('/weight') 88 | ->see('YES-SEE-THIS') 89 | ->dontSee('DONT-SEE-THIS'); 90 | } 91 | 92 | public function testMetaDataStorage() 93 | { 94 | include('source/helper.php'); 95 | 96 | $meta = laravel_ab_meta(); 97 | 98 | Session::forget(config('laravel-ab.cache_key')); 99 | Session::flush(); 100 | 101 | $ab = app()->make('Ab'); 102 | $ab->forceReset(); 103 | Ab::saveSession(); 104 | 105 | $instance = Instance::where(['instance'=>Ab::getSession()->instance])->get()->first(); 106 | $metadata = $instance->metadata; 107 | $this->assertTrue(is_array($metadata)); 108 | $this->assertEquals($metadata, $meta); 109 | 110 | 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/App/Console/Commands/AbReport.php: -------------------------------------------------------------------------------- 1 | argument('experiment', false); 36 | $list = $this->option('list', false); 37 | 38 | if ($list == true) { 39 | $this->prettyPrint($this->listReports()); 40 | 41 | return true; 42 | } 43 | 44 | if (!empty($experiment)) { 45 | $this->prettyPrint($this->printReport($experiment)); 46 | } else { 47 | $reports = $this->listReports(); 48 | $info = []; 49 | foreach ($reports as $report) { 50 | $info[$report->experiment] = $this->printReport($report->experiment); 51 | } 52 | $this->prettyPrint($info); 53 | } 54 | } 55 | 56 | public function prettyPrint($info) 57 | { 58 | $this->info(json_encode($info, JSON_PRETTY_PRINT)); 59 | } 60 | 61 | public function printReport($experiment) 62 | { 63 | $info = []; 64 | 65 | $full_count = 66 | \DB::table('ab_events') 67 | ->select(\DB::raw('ab_events.value,count(*) as hits')) 68 | ->where('ab_events.name', '=', (string) $experiment) 69 | ->groupBy('ab_events.value') 70 | ->get(); 71 | 72 | foreach ($full_count as $record) { 73 | $info[$record->value] = [ 74 | 'condition' => $record->value, 75 | 'hits' => $record->hits, 76 | 'goals' => 0, 77 | 'conversion' => 0, 78 | ]; 79 | } 80 | 81 | $goal_count = \DB::table('ab_events') 82 | ->select(\DB::raw('ab_events.value,count(ab_events.value) as goals')) 83 | ->join('ab_goal', 'ab_goal.instance_id', '=', 'ab_events.instance_id') 84 | ->where('ab_events.name', '=', (string) $experiment) 85 | ->groupBy('ab_events.value') 86 | ->get(); 87 | 88 | foreach ($goal_count as $record) { 89 | $info[$record->value]['goals'] = $record->goals; 90 | $info[$record->value]['conversion'] = ($record->goals / $info[$record->value]['hits']) * 100; 91 | } 92 | 93 | usort($info, function ($a, $b) { 94 | return $a['conversion'] < $b['conversion']; 95 | }); 96 | 97 | return $info; 98 | } 99 | 100 | public function listReports() 101 | { 102 | $info = 103 | \DB::table('ab_experiments') 104 | ->join('ab_events', 'ab_events.experiments_id', '=', 'ab_experiments.id') 105 | ->select(\DB::raw('ab_experiments.experiment, count(*) as hits')) 106 | ->groupBy('ab_experiments.id') 107 | ->get(); 108 | 109 | return $info; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/LaravelAbServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 27 | __DIR__.'/config/config.php' => config_path('laravel-ab.php'), 28 | ], 'config'); 29 | } 30 | 31 | /** 32 | * Register the service provider. 33 | */ 34 | public function register() 35 | { 36 | $this->mergeConfigFrom( 37 | __DIR__.'/config/config.php', 'laravel-ab' 38 | ); 39 | $this->app->make('Illuminate\Contracts\Http\Kernel')->prependMiddleware('\ComoCode\LaravelAb\App\Http\Middleware\LaravelAbMiddleware'); 40 | $this->app->bind('Ab', 'ComoCode\LaravelAb\App\Ab'); 41 | $this->registerCompiler(); 42 | $this->registerCommands(); 43 | } 44 | 45 | public function registerCommands() 46 | { 47 | $this->app->singleton('command.ab.migrate', function ($app) { 48 | return new AbMigrate(); 49 | }); 50 | 51 | $this->app->singleton('command.ab.rollback', function ($app) { 52 | return new AbRollback(); 53 | }); 54 | 55 | $this->app->singleton('command.ab.report', function ($app) { 56 | return new AbReport(); 57 | }); 58 | 59 | $this->commands('command.ab.migrate'); 60 | $this->commands('command.ab.rollback'); 61 | $this->commands('command.ab.report'); 62 | } 63 | /** 64 | * Get the services provided by the provider. 65 | * 66 | * @return array 67 | */ 68 | public function provides() 69 | { 70 | return [ 71 | 'command.ab.migrate', 72 | 'command.ab.rollback', 73 | 'command.ab.report', 74 | ]; 75 | } 76 | 77 | public function registerCompiler() 78 | { 79 | Blade::extend(function ($view, $compiler) { 80 | 81 | while (preg_match_all('/@ab(?:.(?!@track|@ab))+.@track\([^\)]+\)+/si', $view, $sections_matches)) { 82 | $sections = current($sections_matches); 83 | foreach ($sections as $block) { 84 | $instance_id = preg_replace('/[^0-9]/', '', microtime().rand(100000, 999999)); 85 | 86 | if (preg_match("/@ab\(([^\)]+)\)/", $block, $match)) { 87 | $experiment_name = preg_replace('/[^a-z0-9\_]/i', '', $match[1]); 88 | $instance = $experiment_name.'_'.$instance_id; 89 | } else { 90 | throw new \Exception('Experiment with not name not allowed'); 91 | } 92 | $copy = preg_replace('/@ab\(.([^\)]+).\)/i', "experiment('{$experiment_name}'); ?>", $block); 93 | 94 | $copy = preg_replace('/@condition\(([^\)]+)\)/i', "condition($1); ?>", $copy); 95 | 96 | $copy = preg_replace('/@track\(([^\)]+)\)/i', "track($1); ?>", $copy); 97 | 98 | $view = str_replace($block, $copy, $view); 99 | } 100 | } 101 | 102 | $view = preg_replace('/@goal\(([^\)]+)\)/i', "goal($1); ?>", $view); 103 | 104 | return $view; 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /tests/source/laravel/database.php: -------------------------------------------------------------------------------- 1 | PDO::FETCH_CLASS, 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Default Database Connection Name 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may specify which of the database connections below you wish 24 | | to use as your default connection for all database work. Of course 25 | | you may use many connections at once using the Database library. 26 | | 27 | */ 28 | 29 | 'default' => 'sqlite', 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Database Connections 34 | |-------------------------------------------------------------------------- 35 | | 36 | | Here are each of the database connections setup for your application. 37 | | Of course, examples of configuring each database platform that is 38 | | supported by Laravel is shown below to make development simple. 39 | | 40 | | 41 | | All database work in Laravel is done through the PHP PDO facilities 42 | | so make sure you have the driver for your particular database of 43 | | choice installed on your machine before you begin development. 44 | | 45 | */ 46 | 47 | 'connections' => [ 48 | 49 | 'sqlite' => [ 50 | 'driver' => 'sqlite', 51 | 'database' => storage_path('database.sqlite'), 52 | 'prefix' => '', 53 | ], 54 | 55 | 'mysql' => [ 56 | 'driver' => 'mysql', 57 | 'host' => env('DB_HOST', 'localhost'), 58 | 'database' => env('DB_DATABASE', 'forge'), 59 | 'username' => env('DB_USERNAME', 'forge'), 60 | 'password' => env('DB_PASSWORD', ''), 61 | 'charset' => 'utf8', 62 | 'collation' => 'utf8_unicode_ci', 63 | 'prefix' => '', 64 | 'strict' => false, 65 | ], 66 | 67 | 'pgsql' => [ 68 | 'driver' => 'pgsql', 69 | 'host' => env('DB_HOST', 'localhost'), 70 | 'database' => env('DB_DATABASE', 'forge'), 71 | 'username' => env('DB_USERNAME', 'forge'), 72 | 'password' => env('DB_PASSWORD', ''), 73 | 'charset' => 'utf8', 74 | 'prefix' => '', 75 | 'schema' => 'public', 76 | ], 77 | 78 | 'sqlsrv' => [ 79 | 'driver' => 'sqlsrv', 80 | 'host' => env('DB_HOST', 'localhost'), 81 | 'database' => env('DB_DATABASE', 'forge'), 82 | 'username' => env('DB_USERNAME', 'forge'), 83 | 'password' => env('DB_PASSWORD', ''), 84 | 'charset' => 'utf8', 85 | 'prefix' => '', 86 | ], 87 | 88 | ], 89 | 90 | /* 91 | |-------------------------------------------------------------------------- 92 | | Migration Repository Table 93 | |-------------------------------------------------------------------------- 94 | | 95 | | This table keeps track of all the migrations that have already run for 96 | | your application. Using this information, we can determine which of 97 | | the migrations on disk haven't actually been run in the database. 98 | | 99 | */ 100 | 101 | 'migrations' => 'migrations', 102 | 103 | /* 104 | |-------------------------------------------------------------------------- 105 | | Redis Databases 106 | |-------------------------------------------------------------------------- 107 | | 108 | | Redis is an open source, fast, and advanced key-value store that also 109 | | provides a richer set of commands than a typical key-value systems 110 | | such as APC or Memcached. Laravel makes it easy to dig right in. 111 | | 112 | */ 113 | 114 | 'redis' => [ 115 | 116 | 'cluster' => false, 117 | 118 | 'default' => [ 119 | 'host' => '127.0.0.1', 120 | 'port' => 6379, 121 | 'database' => 0, 122 | ], 123 | 124 | ], 125 | 126 | ]; 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/comocode/laravel-ab.png)](https://travis-ci.org/comocode/laravel-ab) 2 | [![Latest Stable Version](https://poser.pugx.org/comocode/laravel-ab/v/stable)](https://packagist.org/packages/comocode/laravel-ab) 3 | [![Total Downloads](https://poser.pugx.org/comocode/laravel-ab/downloads)](https://packagist.org/packages/comocode/laravel-ab) 4 | [![Latest Unstable Version](https://poser.pugx.org/comocode/laravel-ab/v/unstable)](https://packagist.org/packages/comocode/laravel-ab) 5 | [![Daily Downloads](https://poser.pugx.org/comocode/laravel-ab/d/daily)](https://packagist.org/packages/comocode/laravel-ab) 6 | 7 | laravel-ab 8 | ========== 9 | 10 | An A/B Testing suite for Laravel which allows multiple and nested experiments. 11 | 12 | This will create trackable experients with as many conditions as you'd like. 13 | And will track conversion on each experiment based on keywords provided. 14 | 15 | You can have nested experiments, its conditions are regular VIEW outputs 16 | making experiments easy to add/remove from your projects. 17 | 18 | 19 | Usage 20 | ========== 21 | 22 | Install using composer or which ever means you prefer 23 | 24 | ``` 25 | composer install comocode/laravel-ab 26 | ``` 27 | 28 | then add the service provider to your app.php in config/ folder like so 29 | 30 | ```php 31 | 32 | ..Illuminate\Validation\ValidationServiceProvider::class, 33 | 34 | ..Illuminate\View\ViewServiceProvider::class, 35 | 36 | ComoCode\LaravelAb\LaravelAbServiceProvider::class 37 | ``` 38 | 39 | Once you have registered the service provider. You can run `php artistan` 40 | and see the following output: 41 | 42 | ab:migrate migrates Laravel-Ab required tables 43 | 44 | ab:rollback removes Laravel-Ab tables 45 | 46 | ab:report --list outputs statistics on your current experiments or the one specified in the command 47 | 48 | 49 | you can run ab:migrate to create the required tables, and ab:rollback to remove them anytime you wish 50 | to view your experiment results, use the export command to see statistics 51 | 52 | 53 | Creating Experiments 54 | ========== 55 | 56 | There are a few PHP A/B and other Laravel packages available. 57 | 58 | This project focuses on providing the ability to test multiple experiments 59 | including nested experiments with a very easy to use blade interface. 60 | 61 | ```php 62 | 63 | @ab('My First Experiment') ///// the name of the experiment 64 | @condition('ConditionOne') /// one possible condition for the experiment 65 |
66 |
67 | @ab('My Nested Experiment') /// an experiment nested within the top experiment 68 | @condition('NestedConditionOne') 69 |

Some tag

70 | @condition('NestedConditionTwo') /// some values 71 |

Some other tag

72 | @condition('NestedConditionThree') 73 |

Another tag

74 | @track('NestedGoal') /// the goal to track this experiment to 75 |
76 |
77 | @condition('ConditionTwo')/// condition for top level test 78 |

other stuff

79 | @track('TopLevelGoal') /// goal for top level test 80 | 81 | ``` 82 | to reach an event simply do 83 | ``` 84 | @goal('NestedGoal') 85 | 86 | ``` 87 | in the targed page or by utilizing app()->make('Ab')->goal('NestedGoal') anywhere in your application execution. 88 | 89 | 90 | ### Weighted Conditions 91 | 92 | if you would like to throttle the decision towards specific conditions you can add a declaration to control the distribution. 93 | For example 94 | ```php 95 | @ab('My Nested Experiment') /// an experiment nested within the top experiment 96 | @condition('NestedConditionOne [2]') 97 |

Some tag

98 | @condition('NestedConditionTwo [1]') /// some values 99 |

Some other tag

100 | @condition('NestedConditionThree [1]') 101 |

Another tag

102 | @track('NestedGoal') /// the goal to track this experiment to 103 | ``` 104 | 105 | Will randomly select a result but will calculate the odd of the result based on the sum of the weights (1 + 1 + 2 = 4) vs its specific weight 2/4, 1/4 1/4. 106 | 107 | 108 | Results 109 | ========== 110 | Once an experiment is executed, it will remember the options provided to the user so experiment choice selections do not change upon revisiting your project. 111 | 112 | A experiment is recorded per instance and goals are tracked to the instance allowing for aggregation on results per condition. 113 | 114 | Contributing 115 | ========== 116 | Please feel free to contribute as A/B testing is an important part for any organization. 117 | 118 | 119 | TODO 120 | ========== 121 | Add queable job to send reports on cron 122 | Add HTML charts 123 | -------------------------------------------------------------------------------- /src/App/Ab.php: -------------------------------------------------------------------------------- 1 | fired condition the view is initiating 20 | * and event key->value pais for the instance 21 | */ 22 | protected static $instance = []; 23 | 24 | /* 25 | * Individual Test Parameters 26 | */ 27 | protected $name; 28 | protected $conditions = []; 29 | protected $fired; 30 | protected $goal; 31 | protected $metadata_callback; 32 | /** 33 | * @var Request 34 | */ 35 | private $request; 36 | 37 | /** 38 | * Create a instance for a user session if there isnt once. 39 | * Load previous event -> fire pairings for this session if exist. 40 | */ 41 | public function __construct(Request $request) 42 | { 43 | $this->request = $request; 44 | $this->ensureUser(false); 45 | } 46 | 47 | public function ensureUser($forceSession = false) 48 | { 49 | if (!Session::has(config('laravel-ab.cache_key')) || $forceSession) { 50 | $uid = md5(uniqid().$this->request->getClientIp()); 51 | $laravel_ab_id = $this->request->cookie(config('laravel-ab.cache_key'), $uid); 52 | if(version_compare(App()->version(), '5.4.0', '>=')) { 53 | Session::put(config('laravel-ab.cache_key'), $uid); 54 | } else { 55 | Session::set(config('laravel-ab.cache_key'), $uid); 56 | } 57 | } 58 | 59 | if (empty(self::$session)) { 60 | self::$session = Instance::firstOrCreate([ 61 | 'instance' => Session::get(config('laravel-ab.cache_key')), 62 | 'identifier' => $this->request->getClientIp(), 63 | 'metadata' => (function_exists('laravel_ab_meta') ? call_user_func('laravel_ab_meta') : null), 64 | ]); 65 | } 66 | } 67 | 68 | /** 69 | * @param array $session_variables 70 | * Load initial session variables to store or track 71 | * Such as variables you want to track being passed into the template. 72 | */ 73 | public function setup(array $session_variables = array()) 74 | { 75 | foreach ($session_variables as $key => $value) { 76 | $experiment = new self(); 77 | $experiment->experiment($key); 78 | $experiment->fired = $value; 79 | $experiment->instanceEvent(); 80 | } 81 | } 82 | 83 | /** 84 | * When the view is rendered, this funciton saves all event->firing pairing to storage. 85 | */ 86 | public static function saveSession() 87 | { 88 | if (!empty(self::$instance)) { 89 | foreach (self::$instance as $event) { 90 | $experiment = Experiments::firstOrCreate([ 91 | 'experiment' => $event->name, 92 | 'goal' => $event->goal, 93 | ]); 94 | 95 | $event = Events::firstOrCreate([ 96 | 'instance_id' => self::$session->id, 97 | 'name' => $event->name, 98 | 'value' => $event->fired, 99 | ]); 100 | 101 | $experiment->events()->save($event); 102 | self::$session->events()->save($event); 103 | } 104 | } 105 | 106 | return Session::get(config('laravel-ab.cache_key')); 107 | } 108 | 109 | /** 110 | * @param $experiment 111 | * 112 | * @return $this 113 | * 114 | * Used to track the name of the experiment 115 | */ 116 | public function experiment($experiment) 117 | { 118 | $this->name = $experiment; 119 | $this->instanceEvent(); 120 | 121 | return $this; 122 | } 123 | 124 | /** 125 | * @param $goal 126 | * 127 | * @return string 128 | * 129 | * Sets the tracking target for the experiment, and returns one of the conditional elements for display 130 | */ 131 | public function track($goal) 132 | { 133 | $this->goal = $goal; 134 | 135 | ob_end_clean(); 136 | 137 | $conditions = []; 138 | foreach ($this->conditions as $key => $condition) { 139 | if (preg_match('/\[(\d+)\]/', $key, $matches)) { 140 | foreach (range(1, $matches[1]) as $index) { 141 | $conditions[] = $key; 142 | } 143 | } 144 | } 145 | if (empty($conditions)) { 146 | $conditions = array_keys($this->conditions); 147 | } 148 | /// has the user fired this particular experiment yet? 149 | if ($fired = $this->hasExperiment($this->name)) { 150 | $this->fired = $fired; 151 | } else { 152 | shuffle($conditions); 153 | $this->fired = current($conditions); 154 | } 155 | 156 | return $this->conditions[$this->fired]; 157 | } 158 | 159 | /** 160 | * @param $goal 161 | * @param goal $value 162 | * 163 | * Insert a simple goal tracker to know if user has reach a milestone 164 | */ 165 | public function goal($goal, $value = null) 166 | { 167 | $goal = Goal::create(['goal' => $goal, 'value' => $value]); 168 | 169 | self::$session->goals()->save($goal); 170 | 171 | return $goal; 172 | } 173 | 174 | /** 175 | * @param $condition 176 | * @returns void 177 | * 178 | * Captures the HTML between AB condtions and tracks them to their condition name. 179 | * One of these conditions will be randomized to some ratio for display and tracked 180 | */ 181 | public function condition($condition) 182 | { 183 | $reference = $this; 184 | 185 | if (count($this->conditions) !== 0) { 186 | ob_end_clean(); 187 | } 188 | 189 | $reference->saveCondition($condition, ''); /// so above count fires after first pass 190 | 191 | ob_start(function ($data) use ($condition, $reference) { 192 | $reference->saveCondition($condition, $data); 193 | }); 194 | } 195 | 196 | /** 197 | * @param bool $forceSession 198 | * 199 | * @return mixed 200 | * 201 | * Ensuring a user session string on any call for a key to be used. 202 | */ 203 | public static function getSession() 204 | { 205 | return self::$session; 206 | } 207 | /** 208 | * @param $condition 209 | * @param $data 210 | * @returns void 211 | * 212 | * A setter for the condition key=>value pairing. 213 | */ 214 | public function saveCondition($condition, $data) 215 | { 216 | $this->conditions[$condition] = $data; 217 | } 218 | 219 | /** 220 | * @param $experiment 221 | * @param $condition 222 | * 223 | * Tracks at an instance level which event was selected for the session 224 | */ 225 | public function instanceEvent() 226 | { 227 | self::$instance[$this->name] = $this; 228 | } 229 | 230 | /** 231 | * @param $experiment 232 | * 233 | * @return bool 234 | * 235 | * Determines if a user has a particular event already in this session 236 | */ 237 | public function hasExperiment($experiment) 238 | { 239 | $session_events = self::$session->events()->get(); 240 | foreach ($session_events as $event) { 241 | if ($event->name == $experiment) { 242 | return $event->value; 243 | } 244 | } 245 | 246 | return false; 247 | } 248 | 249 | /** 250 | * Simple method for resetting the session variable for development purposes. 251 | */ 252 | public function forceReset() 253 | { 254 | self::resetSession(); 255 | $this->ensureUser(true); 256 | } 257 | 258 | public function toArray() 259 | { 260 | return [$this->name => $this->fired]; 261 | } 262 | 263 | public function getEvents() 264 | { 265 | return self::$instance; 266 | } 267 | 268 | public static function resetSession() 269 | { 270 | self::$session = false; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /tests/source/laravel/app.php: -------------------------------------------------------------------------------- 1 | true, 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Application URL 21 | |-------------------------------------------------------------------------- 22 | | 23 | | This URL is used by the console to properly generate URLs when using 24 | | the Artisan command line tool. You should set this to the root of 25 | | your application so that it is used when running Artisan tasks. 26 | | 27 | */ 28 | 29 | 'url' => 'http://localhost:8000', 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Application Timezone 34 | |-------------------------------------------------------------------------- 35 | | 36 | | Here you may specify the default timezone for your application, which 37 | | will be used by the PHP date and date-time functions. We have gone 38 | | ahead and set this to a sensible default for you out of the box. 39 | | 40 | */ 41 | 42 | 'timezone' => 'UTC', 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Application Locale Configuration 47 | |-------------------------------------------------------------------------- 48 | | 49 | | The application locale determines the default locale that will be used 50 | | by the translation service provider. You are free to set this value 51 | | to any of the locales which will be supported by the application. 52 | | 53 | */ 54 | 55 | 'locale' => 'en', 56 | 57 | /* 58 | |-------------------------------------------------------------------------- 59 | | Application Fallback Locale 60 | |-------------------------------------------------------------------------- 61 | | 62 | | The fallback locale determines the locale to use when the current one 63 | | is not available. You may change the value to correspond to any of 64 | | the language folders that are provided through your application. 65 | | 66 | */ 67 | 68 | 'fallback_locale' => 'en', 69 | 70 | /* 71 | |-------------------------------------------------------------------------- 72 | | Encryption Key 73 | |-------------------------------------------------------------------------- 74 | | 75 | | This key is used by the Illuminate encrypter service and should be set 76 | | to a random, 32 character string, otherwise these encrypted strings 77 | | will not be safe. Please do this before deploying an application! 78 | | 79 | */ 80 | 81 | 'key' => env('APP_KEY', 'SomeRandomString'), 82 | 83 | 'cipher' => 'AES-256-CBC', 84 | 85 | /* 86 | |-------------------------------------------------------------------------- 87 | | Logging Configuration 88 | |-------------------------------------------------------------------------- 89 | | 90 | | Here you may configure the log settings for your application. Out of 91 | | the box, Laravel uses the Monolog PHP logging library. This gives 92 | | you a variety of powerful log handlers / formatters to utilize. 93 | | 94 | | Available Settings: "single", "daily", "syslog", "errorlog" 95 | | 96 | */ 97 | 98 | 'log' => 'single', 99 | 100 | /* 101 | |-------------------------------------------------------------------------- 102 | | Autoloaded Service Providers 103 | |-------------------------------------------------------------------------- 104 | | 105 | | The service providers listed here will be automatically loaded on the 106 | | request to your application. Feel free to add your own services to 107 | | this array to grant expanded functionality to your applications. 108 | | 109 | */ 110 | 111 | 'providers' => [ 112 | 113 | /* 114 | * Laravel Framework Service Providers... 115 | */ 116 | Illuminate\Foundation\Providers\ArtisanServiceProvider::class, 117 | Illuminate\Auth\AuthServiceProvider::class, 118 | Illuminate\Broadcasting\BroadcastServiceProvider::class, 119 | Illuminate\Bus\BusServiceProvider::class, 120 | Illuminate\Cache\CacheServiceProvider::class, 121 | Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, 122 | Illuminate\Cookie\CookieServiceProvider::class, 123 | Illuminate\Database\DatabaseServiceProvider::class, 124 | Illuminate\Encryption\EncryptionServiceProvider::class, 125 | Illuminate\Filesystem\FilesystemServiceProvider::class, 126 | Illuminate\Foundation\Providers\FoundationServiceProvider::class, 127 | Illuminate\Hashing\HashServiceProvider::class, 128 | Illuminate\Mail\MailServiceProvider::class, 129 | Illuminate\Pagination\PaginationServiceProvider::class, 130 | Illuminate\Pipeline\PipelineServiceProvider::class, 131 | Illuminate\Queue\QueueServiceProvider::class, 132 | Illuminate\Redis\RedisServiceProvider::class, 133 | Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, 134 | Illuminate\Session\SessionServiceProvider::class, 135 | Illuminate\Translation\TranslationServiceProvider::class, 136 | Illuminate\Validation\ValidationServiceProvider::class, 137 | Illuminate\View\ViewServiceProvider::class, 138 | 139 | /* 140 | * Application Service Providers... 141 | */ 142 | App\Providers\AppServiceProvider::class, 143 | App\Providers\AuthServiceProvider::class, 144 | App\Providers\EventServiceProvider::class, 145 | App\Providers\RouteServiceProvider::class, 146 | ComoCode\LaravelAb\LaravelAbServiceProvider::class 147 | 148 | ], 149 | 150 | /* 151 | |-------------------------------------------------------------------------- 152 | | Class Aliases 153 | |-------------------------------------------------------------------------- 154 | | 155 | | This array of class aliases will be registered when this application 156 | | is started. However, feel free to register as many as you wish as 157 | | the aliases are "lazy" loaded so they don't hinder performance. 158 | | 159 | */ 160 | 161 | 'aliases' => [ 162 | 163 | 'App' => Illuminate\Support\Facades\App::class, 164 | 'Artisan' => Illuminate\Support\Facades\Artisan::class, 165 | 'Auth' => Illuminate\Support\Facades\Auth::class, 166 | 'Blade' => Illuminate\Support\Facades\Blade::class, 167 | 'Bus' => Illuminate\Support\Facades\Bus::class, 168 | 'Cache' => Illuminate\Support\Facades\Cache::class, 169 | 'Config' => Illuminate\Support\Facades\Config::class, 170 | 'Cookie' => Illuminate\Support\Facades\Cookie::class, 171 | 'Crypt' => Illuminate\Support\Facades\Crypt::class, 172 | 'DB' => Illuminate\Support\Facades\DB::class, 173 | 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 174 | 'Event' => Illuminate\Support\Facades\Event::class, 175 | 'File' => Illuminate\Support\Facades\File::class, 176 | 'Gate' => Illuminate\Support\Facades\Gate::class, 177 | 'Hash' => Illuminate\Support\Facades\Hash::class, 178 | 'Input' => Illuminate\Support\Facades\Input::class, 179 | 'Inspiring' => Illuminate\Foundation\Inspiring::class, 180 | 'Lang' => Illuminate\Support\Facades\Lang::class, 181 | 'Log' => Illuminate\Support\Facades\Log::class, 182 | 'Mail' => Illuminate\Support\Facades\Mail::class, 183 | 'Password' => Illuminate\Support\Facades\Password::class, 184 | 'Queue' => Illuminate\Support\Facades\Queue::class, 185 | 'Redirect' => Illuminate\Support\Facades\Redirect::class, 186 | 'Redis' => Illuminate\Support\Facades\Redis::class, 187 | 'Request' => Illuminate\Support\Facades\Request::class, 188 | 'Response' => Illuminate\Support\Facades\Response::class, 189 | 'Route' => Illuminate\Support\Facades\Route::class, 190 | 'Schema' => Illuminate\Support\Facades\Schema::class, 191 | 'Session' => Illuminate\Support\Facades\Session::class, 192 | 'Storage' => Illuminate\Support\Facades\Storage::class, 193 | 'URL' => Illuminate\Support\Facades\URL::class, 194 | 'Validator' => Illuminate\Support\Facades\Validator::class, 195 | 'View' => Illuminate\Support\Facades\View::class, 196 | 197 | ], 198 | 199 | ]; 200 | --------------------------------------------------------------------------------