diff --git a/.gitattributes b/.gitattributes
index 6945750..07e9055 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,5 +5,7 @@
.gitignore export-ignore
phpunit.xml export-ignore
README.md export-ignore
+testbench.yaml export-ignore
tests/ export-ignore
UPGRADING.md export-ignore
+workbench/ export-ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1170f0d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,132 @@
+name: CI
+
+on:
+ pull_request:
+ types: [ready_for_review, synchronize, opened]
+ paths-ignore:
+ - "README.md"
+ push:
+ branches: [main]
+ paths-ignore:
+ - "README.md"
+
+jobs:
+ pest:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php: [ '8.2', '8.3', '8.4' ]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ tools: composer:v2
+ coverage: none
+ extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer, pdo, sqlite3
+ ini-values: error_reporting=E_ALL, display_errors=On, log_errors=Off
+
+ - name: Determine composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache composer
+ uses: actions/cache@v3
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-composer-
+
+ - name: Validate composer.json
+ run: composer validate --no-check-publish
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --no-interaction
+
+ - name: Run Pest
+ run: vendor/bin/pest --no-coverage
+ env:
+ CI: true
+
+ benchmark:
+ needs: pest
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ tools: composer:v2
+ coverage: none
+ extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --no-interaction
+
+ - name: Run benchmark
+ run: vendor/bin/testbench benchmark --ci > benchmark-comment.md
+
+ - name: Find existing comment
+ uses: peter-evans/find-comment@v3
+ id: find
+ with:
+ issue-number: ${{ github.event.pull_request.number }}
+ comment-author: 'github-actions[bot]'
+ body-includes: '## Benchmark Results'
+
+ - name: Post or update comment
+ uses: peter-evans/create-or-update-comment@v4
+ with:
+ issue-number: ${{ github.event.pull_request.number }}
+ comment-id: ${{ steps.find.outputs.comment-id }}
+ edit-mode: replace
+ body-path: benchmark-comment.md
+
+ snapshot:
+ needs: pest
+ if: github.event_name == 'push'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ tools: composer:v2
+ coverage: none
+ extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --no-interaction
+
+ - name: Generate snapshot
+ run: vendor/bin/testbench benchmark --snapshot --ci
+
+ - name: Commit snapshot
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add .github/benchmark-snapshot.json
+ git diff --staged --quiet || git commit -m "Update benchmark snapshot"
+ git push
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
deleted file mode 100644
index e4d51f6..0000000
--- a/.github/workflows/tests.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-name: Tests
-
-on:
- push:
- branches: [ "**" ]
- pull_request:
- branches: [ "**" ]
-
-jobs:
- pest:
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- php: [ '8.2', '8.3', '8.4' ]
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php }}
- tools: composer:v2
- coverage: none
- extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer, pdo, sqlite3
- ini-values: error_reporting=E_ALL, display_errors=On, log_errors=Off
-
- - name: Determine composer cache directory
- id: composer-cache
- run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
-
- - name: Cache composer
- uses: actions/cache@v3
- with:
- path: ${{ steps.composer-cache.outputs.dir }}
- key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
- restore-keys: |
- ${{ runner.os }}-composer-
-
- - name: Validate composer.json
- run: composer validate --no-check-publish
-
- - name: Install dependencies
- run: composer install --prefer-dist --no-progress --no-interaction
-
- - name: Run Pest
- run: vendor/bin/pest --no-coverage
- env:
- CI: true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index cd37346..76f97fa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,5 +9,4 @@ composer.lock
phpunit.xml.dist
.env
.env.testing
-tests/fixtures/compiled/
-tests/fixtures/blaze/
\ No newline at end of file
+benchmark-snapshot.json
\ No newline at end of file
diff --git a/composer.json b/composer.json
index 0e2d847..887719c 100644
--- a/composer.json
+++ b/composer.json
@@ -30,7 +30,8 @@
},
"autoload-dev": {
"psr-4": {
- "Livewire\\Blaze\\Tests\\": "tests/"
+ "Livewire\\Blaze\\Tests\\": "tests/",
+ "Workbench\\App\\": "workbench/app/"
}
},
"extra": {
@@ -49,6 +50,8 @@
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
- "test": "vendor/bin/pest"
+ "test": "vendor/bin/pest",
+ "benchmark": "vendor/bin/testbench benchmark",
+ "benchmark:snapshot": "vendor/bin/testbench benchmark --snapshot"
}
-}
+}
\ No newline at end of file
diff --git a/testbench.yaml b/testbench.yaml
new file mode 100644
index 0000000..a28fae2
--- /dev/null
+++ b/testbench.yaml
@@ -0,0 +1,7 @@
+laravel: '@testbench'
+
+workbench:
+ install: true
+ discovers:
+ commands: true
+ views: true
diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php
new file mode 100644
index 0000000..dd99226
--- /dev/null
+++ b/workbench/app/Console/Commands/BenchmarkCommand.php
@@ -0,0 +1,323 @@
+iterations = (int) $this->option('iterations');
+ $this->rounds = (int) $this->option('rounds');
+ $this->warmupRounds = (int) $this->option('warmup');
+
+ Artisan::call('view:clear');
+
+ $results = $this->runBenchmarks();
+
+ $this->option('ci')
+ ? $this->outputMarkdown($results)
+ : $this->displayResults($results);
+
+ if ($this->option('snapshot')) {
+ $this->saveSnapshot($results);
+ }
+
+ return Command::SUCCESS;
+ }
+
+ protected function runBenchmarks(): array
+ {
+ $showProgress = ! $this->option('ci');
+ $benchmarks = $this->getBenchmarks();
+ $names = array_keys($benchmarks);
+
+ if ($showProgress) {
+ $this->info("Running benchmarks ({$this->iterations} iterations x {$this->rounds} rounds)...");
+ $this->newLine();
+ }
+
+ $totalSteps = (count($benchmarks) * $this->warmupRounds) + ($this->rounds * count($benchmarks));
+ $bar = $showProgress ? $this->output->createProgressBar($totalSteps) : null;
+ $bar?->setFormat(' %current%/%max% [%bar%] %message%');
+ $bar?->setMessage('Warming up...');
+ $bar?->start();
+
+ // Warmup: compile views and stabilize opcache.
+ foreach ($benchmarks as $benchmark) {
+ for ($w = 0; $w < $this->warmupRounds; $w++) {
+ $this->measureView($benchmark['blade']);
+ $this->measureView($benchmark['blaze']);
+ $bar?->advance();
+ }
+ }
+
+ $bladeTimes = array_fill_keys($names, []);
+ $blazeTimes = array_fill_keys($names, []);
+
+ $bar?->setMessage('Benchmarking...');
+
+ for ($r = 0; $r < $this->rounds; $r++) {
+ foreach ($benchmarks as $name => $benchmark) {
+ $bladeTimes[$name][] = $this->measureView($benchmark['blade']);
+ $blazeTimes[$name][] = $this->measureView($benchmark['blaze']);
+ $bar?->advance();
+ }
+ }
+
+ $bar?->setMessage('Done!');
+ $bar?->finish();
+
+ if ($showProgress) {
+ $this->newLine(2);
+ }
+
+ return collect($names)->mapWithKeys(fn ($name) => [
+ $name => [
+ 'blade_ms' => round(collect($bladeTimes[$name])->median(), 2),
+ 'blaze_ms' => round(collect($blazeTimes[$name])->median(), 2),
+ ],
+ ])->all();
+ }
+
+ protected function buildTable(array $results): array
+ {
+ $snapshot = $this->option('snapshot') ? null : $this->loadSnapshot();
+
+ $headers = ['Benchmark', 'Blade', 'Blaze', 'Improvement'];
+
+ $rows = collect($results)->map(function ($result, $name) use ($snapshot) {
+ $blade = $this->formatTime($result['blade_ms']);
+ $blaze = $this->formatTime($result['blaze_ms']);
+ $improvement = $this->improvement($result) . '%';
+
+ if ($prev = $snapshot['benchmarks'][$name] ?? null) {
+ $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms'], 10.0);
+ $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms'], 5.0);
+ $improvement .= ' ' . $this->formatChange($prev['improvement'], $this->improvement($result), 1.0);
+ }
+
+ return [$name, $blade, $blaze, $improvement];
+ })->values()->all();
+
+ return [$headers, $rows, $snapshot];
+ }
+
+ protected function displayResults(array $results): void
+ {
+ [$headers, $rows, $snapshot] = $this->buildTable($results);
+
+ $this->newLine();
+ $this->table($headers, $rows);
+
+ $this->newLine();
+ $this->info("{$this->iterations} iterations x {$this->rounds} rounds per benchmark");
+
+ if ($snapshot) {
+ $rounds = $snapshot['rounds'] ?? 1;
+ $this->comment("Compared against snapshot ({$snapshot['iterations']} iterations x {$rounds} rounds)");
+ }
+ }
+
+ protected function outputMarkdown(array $results): void
+ {
+ [$headers, $rows, $snapshot] = $this->buildTable($results);
+
+ $allRows = collect([$headers, ...$rows]);
+ $widths = collect($headers)->keys()->map(
+ fn ($i) => $allRows->max(fn ($row) => mb_strlen($row[$i]))
+ );
+
+ $formatRow = fn ($cells) => '| ' . collect($cells)
+ ->map(fn ($cell, $i) => Str::padRight($cell, $widths[$i]))
+ ->implode(' | ') . ' |';
+
+ $separator = '| ' . $widths->map(fn ($w) => str_repeat('-', $w))->implode(' | ') . ' |';
+
+ $md = collect([
+ '## Benchmark Results',
+ '',
+ $formatRow($headers),
+ $separator,
+ ...collect($rows)->map($formatRow),
+ '',
+ '' . "{$this->iterations} iterations x {$this->rounds} rounds per benchmark"
+ . ($snapshot ? ' — compared against committed snapshot' : '')
+ . '',
+ ])->implode("\n");
+
+ $this->output->writeln($md);
+ }
+
+ protected function saveSnapshot(array $results): void
+ {
+ $existing = $this->loadSnapshot();
+
+ if ($existing && ! $this->hasSignificantChange($results, $existing)) {
+ $this->newLine();
+ $this->comment('Snapshot unchanged (no significant difference).');
+
+ return;
+ }
+
+ $snapshot = [
+ 'iterations' => $this->iterations,
+ 'rounds' => $this->rounds,
+ 'benchmarks' => collect($results)->map(fn ($result) => [
+ 'blade_ms' => $result['blade_ms'],
+ 'blaze_ms' => $result['blaze_ms'],
+ 'improvement' => $this->improvement($result),
+ ])->all(),
+ ];
+
+ $path = $this->snapshotPath();
+
+ File::put($path, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
+
+ $this->newLine();
+ $this->info("Snapshot saved to {$path}");
+ }
+
+ protected function hasSignificantChange(array $results, array $snapshot): bool
+ {
+ if (array_keys($results) !== array_keys($snapshot['benchmarks'])) {
+ return true;
+ }
+
+ return collect($results)->contains(function ($result, $name) use ($snapshot) {
+ $prev = $snapshot['benchmarks'][$name];
+
+ return $this->exceedsThreshold($prev['blade_ms'], $result['blade_ms'], 10.0)
+ || $this->exceedsThreshold($prev['blaze_ms'], $result['blaze_ms'], 5.0);
+ });
+ }
+
+ protected function exceedsThreshold(float $old, float $new, float $threshold): bool
+ {
+ return $old > 0 && abs(($new - $old) / $old * 100) >= $threshold;
+ }
+
+ protected function loadSnapshot(): ?array
+ {
+ $path = $this->snapshotPath();
+
+ if (! File::exists($path)) {
+ return null;
+ }
+
+ $data = File::json($path);
+
+ return is_array($data) && isset($data['benchmarks']) ? $data : null;
+ }
+
+ protected function snapshotPath(): string
+ {
+ $root = dirname(__DIR__, 4);
+
+ return $this->option('ci')
+ ? $root . '/.github/benchmark-snapshot.json'
+ : $root . '/benchmark-snapshot.json';
+ }
+
+ protected function improvement(array $result): float
+ {
+ return $result['blade_ms'] > 0
+ ? round((1 - $result['blaze_ms'] / $result['blade_ms']) * 100, 1)
+ : 0;
+ }
+
+ protected function formatChange(float $old, float $new, float $threshold): string
+ {
+ if ($old == 0) {
+ return '(~)';
+ }
+
+ $change = ($new - $old) / abs($old) * 100;
+
+ if (abs($change) < $threshold) {
+ return '(~)';
+ }
+
+ $sign = $change > 0 ? '+' : '';
+
+ return "({$sign}" . round($change, 1) . '%)';
+ }
+
+ protected function getBenchmarks(): array
+ {
+ return [
+ 'No attributes' => [
+ 'blade' => 'bench.blade.no-attributes',
+ 'blaze' => 'bench.blaze.no-attributes',
+ ],
+ 'Attributes only' => [
+ 'blade' => 'bench.blade.attributes',
+ 'blaze' => 'bench.blaze.attributes',
+ ],
+ 'Attributes + merge()' => [
+ 'blade' => 'bench.blade.merge',
+ 'blaze' => 'bench.blaze.merge',
+ ],
+ 'Attributes + class()' => [
+ 'blade' => 'bench.blade.class',
+ 'blaze' => 'bench.blaze.class',
+ ],
+ 'Props + attributes' => [
+ 'blade' => 'bench.blade.props',
+ 'blaze' => 'bench.blaze.props',
+ ],
+ 'Default slot' => [
+ 'blade' => 'bench.blade.slot',
+ 'blaze' => 'bench.blaze.slot',
+ ],
+ 'Named slots' => [
+ 'blade' => 'bench.blade.named-slots',
+ 'blaze' => 'bench.blaze.named-slots',
+ ],
+ '`@aware` (nested)' => [
+ 'blade' => 'bench.blade.aware',
+ 'blaze' => 'bench.blaze.aware',
+ ],
+ 'Attribute forwarding' => [
+ 'blade' => 'bench.blade.forwarding',
+ 'blaze' => 'bench.blaze.forwarding',
+ ],
+ ];
+ }
+
+ protected function renderView(string $view): string
+ {
+ return View::make($view, ['iterations' => $this->iterations])->render();
+ }
+
+ protected function measureView(string $view): float
+ {
+ return Benchmark::measure(fn () => $this->renderView($view));
+ }
+
+ protected function formatTime(float $ms): string
+ {
+ return number_format($ms, 2) . 'ms';
+ }
+}
diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php
new file mode 100644
index 0000000..e8cec9c
--- /dev/null
+++ b/workbench/app/Providers/WorkbenchServiceProvider.php
@@ -0,0 +1,24 @@
+
+@endfor
diff --git a/workbench/resources/views/bench/blade/aware.blade.php b/workbench/resources/views/bench/blade/aware.blade.php
new file mode 100644
index 0000000..987cd5c
--- /dev/null
+++ b/workbench/resources/views/bench/blade/aware.blade.php
@@ -0,0 +1,6 @@
+@for ($i = 0; $i < $iterations; $i++)
+