├── .github └── assets │ └── example.png ├── tests ├── Unit │ └── ExampleTest.php ├── Feature │ └── ExampleTest.php ├── TestCase.php ├── Fixtures │ └── ServiceProvider.php └── Pest.php ├── .gitignore ├── src ├── helpers.php ├── Commands │ └── ClearOGImageCache.php ├── Controllers │ └── OGImageController.php ├── Image.php ├── ServiceProvider.php ├── Generator.php └── Base64EncodedFile.php ├── config └── og-image.php ├── LICENSE.md ├── phpunit.xml ├── composer.json └── README.md /.github/assets/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aniftyco/laravel-og-image/HEAD/.github/assets/example.png -------------------------------------------------------------------------------- /tests/Unit/ExampleTest.php: -------------------------------------------------------------------------------- 1 | toBeTrue(); 5 | }); 6 | -------------------------------------------------------------------------------- /tests/Feature/ExampleTest.php: -------------------------------------------------------------------------------- 1 | get('/'); 5 | 6 | $response->assertStatus(200); 7 | }); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.phpunit.cache 2 | /node_modules 3 | /public/build 4 | /public/hot 5 | /public/storage 6 | /storage/*.key 7 | /vendor 8 | .env 9 | .env.backup 10 | .env.production 11 | .phpactor.json 12 | .phpunit.result.cache 13 | Homestead.json 14 | Homestead.yaml 15 | auth.json 16 | npm-debug.log 17 | yarn-error.log 18 | /.fleet 19 | /.idea 20 | /.vscode 21 | composer.lock -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 'hello world'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | make($view, $data); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Commands/ClearOGImageCache.php: -------------------------------------------------------------------------------- 1 | laravel['files']->exists($dir)) { 21 | $this->laravel['files']->cleanDirectory($dir); 22 | } 23 | 24 | $this->info('OG image cache cleared!'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /config/og-image.php: -------------------------------------------------------------------------------- 1 | env('CHROME_PATH'), 7 | 'template_dir' => 'og-image', 8 | 'no_sandbox' => false, 9 | 'ignore_certificate_errors' => true, 10 | 'custom_flags' => [ 11 | '--disable-gpu', 12 | '--disable-dev-shm-usage', 13 | '--disable-setuid-sandbox', 14 | ], 15 | 'event_name' => Page::NETWORK_IDLE, 16 | 'view_port' => [ 17 | 'width' => 1200, 18 | 'height' => 630, 19 | ], 20 | 'name' => 'og-image', 21 | 'cache' => [ 22 | 'maxage' => 60 * 60 * 24 * 7, // 1 week 23 | 'revalidate' => 60 * 60 * 24, // 1 day 24 | ] 25 | ]; 26 | -------------------------------------------------------------------------------- /src/Controllers/OGImageController.php: -------------------------------------------------------------------------------- 1 | query())); 12 | $dir = storage_path('framework/cache/og-images'); 13 | $path = "{$dir}/{$hash}.png"; 14 | 15 | // Ensure the directory exists 16 | if (!app('files')->exists($dir)) { 17 | app('files')->makeDirectory($dir); 18 | } 19 | 20 | // does the image already exist? 21 | if (app('files')->exists($path)) { 22 | return response()->file($path, headers: ['Content-Type' => 'image/png']); 23 | } 24 | 25 | $image = app('og-image.generator')->make($request->get('template'), $request->except('template')); 26 | 27 | app('files')->put($path, $image->toPng()); 28 | 29 | return $image; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 NiftyCo, LLC 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 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests/Unit 10 | 11 | 12 | ./tests/Feature 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ./src 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Image.php: -------------------------------------------------------------------------------- 1 | getBase64())); 15 | 16 | $this->headers->set('content-type', 'image/png'); 17 | $this->headers->set('cache-control', implode(', ', [ 18 | 'public', 19 | 'no-transform', 20 | 'max-age=' . $config['cache']['maxage'], 21 | 'stale-while-revalidate=' . $config['cache']['revalidate'], 22 | ])); 23 | } 24 | 25 | private function getFile(): Base64EncodedFile 26 | { 27 | return $this->file ??= new Base64EncodedFile($this->screenshot->getBase64(), $this->config['name'] . '.png', 'image/png'); 28 | } 29 | 30 | public function toPng(): string 31 | { 32 | return base64_decode($this->screenshot->getBase64()); 33 | } 34 | 35 | public function __call($method, $parameters) 36 | { 37 | return $this->getFile()->$method(...$parameters); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom( 19 | __DIR__ . '/../config/og-image.php', 20 | 'og-image' 21 | ); 22 | 23 | $this->app->singleton('og-image.generator', function ($app) { 24 | return new Generator($app['view'], $app['config']['og-image']); 25 | }); 26 | } 27 | 28 | /** 29 | * Bootstrap any application services. 30 | */ 31 | public function boot(): void 32 | { 33 | if ($this->app->runningInConsole()) { 34 | $this->commands([ 35 | Commands\ClearOGImageCache::class, 36 | ]); 37 | } 38 | 39 | $this->publishes([ 40 | __DIR__ . '/../config/og-image.php' => config_path('og-image.php'), 41 | ]); 42 | 43 | RateLimiter::for('og-image', fn(Request $request) => Limit::perSecond(1)->by($request->ip())); 44 | 45 | $this->app->make('router')->get('/og-image', OGImageController::class) 46 | ->name('og-image') 47 | ->middleware('throttle:og-image'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | extend(Tests\TestCase::class) 15 | // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) 16 | ->in('Feature'); 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Expectations 21 | |-------------------------------------------------------------------------- 22 | | 23 | | When you're writing tests, you often need to check that values meet certain conditions. The 24 | | "expect()" function gives you access to a set of "expectations" methods that you can use 25 | | to assert different things. Of course, you may extend the Expectation API at any time. 26 | | 27 | */ 28 | 29 | expect()->extend('toBeOne', function () { 30 | return $this->toBe(1); 31 | }); 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Functions 36 | |-------------------------------------------------------------------------- 37 | | 38 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 39 | | project that you don't want to repeat in every file. Here you can also expose helpers as 40 | | global functions to help you to reduce the number of lines of code in your test files. 41 | | 42 | */ 43 | 44 | function something() 45 | { 46 | // .. 47 | } 48 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aniftyco/laravel-og-image", 3 | "description": "Open Graph Image Generator for Laravel", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "NiftyCo, LLC", 8 | "homepage": "https://aniftyco.com" 9 | }, 10 | { 11 | "name": "Josh Manders", 12 | "homepage": "https://x.com/joshmanders" 13 | } 14 | ], 15 | "homepage": "https://github.com/aniftyco/laravel-og-image", 16 | "keywords": [ 17 | "Laravel", 18 | "Open Graph Protocol", 19 | "Open Graph Images", 20 | "OG Image" 21 | ], 22 | "require": { 23 | "php": "^8.1", 24 | "chrome-php/chrome": "^1.11" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "NiftyCo\\OgImage\\": "src/" 29 | }, 30 | "files": [ 31 | "src/helpers.php" 32 | ] 33 | }, 34 | "require-dev": { 35 | "orchestra/testbench": "^9.5", 36 | "pestphp/pest": "^3.3" 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "Tests\\": "tests/", 41 | "Workbench\\App\\": "workbench/app/", 42 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 43 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 44 | } 45 | }, 46 | "scripts": { 47 | "post-autoload-dump": [ 48 | "@clear", 49 | "@prepare" 50 | ], 51 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 52 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 53 | "build": "@php vendor/bin/testbench workbench:build --ansi", 54 | "serve": [ 55 | "Composer\\Config::disableProcessTimeout", 56 | "@build", 57 | "@php vendor/bin/testbench serve --ansi" 58 | ], 59 | "lint": [ 60 | "@php vendor/bin/phpstan analyse --verbose --ansi" 61 | ], 62 | "test": "@php vendor/bin/pest" 63 | }, 64 | "extra": { 65 | "laravel": { 66 | "providers": [ 67 | "NiftyCo\\OgImage\\ServiceProvider" 68 | ] 69 | } 70 | }, 71 | "config": { 72 | "allow-plugins": { 73 | "pestphp/pest-plugin": true 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Generator.php: -------------------------------------------------------------------------------- 1 | browser = (new BrowserFactory($config['binary']))->createBrowser([ 19 | 'noSandbox' => $config['no_sandbox'], 20 | 'ignoreCertificateErrors' => $config['ignore_certificate_errors'], 21 | 'customFlags' => $config['custom_flags'], 22 | ]); 23 | } 24 | 25 | public function make(string $view, array $data = []): Image 26 | { 27 | $this->page = $this->browser->createPage(); 28 | $this->page->setHtml($this->view->make($this->config['template_dir'] . '.' . $view, $data)->render(), eventName: $this->config['event_name']); 29 | 30 | $this->page->evaluate($this->injectJs()); 31 | 32 | $this->page->setViewport($this->config['view_port']['width'], $this->config['view_port']['height']); 33 | 34 | return new Image($this->page->screenshot(), $this->config); 35 | } 36 | 37 | private function injectJs(): string 38 | { 39 | // Wait until all images and fonts have loaded 40 | // Taken from: https://github.com/svycal/og-image/blob/main/priv/js/take-screenshot.js#L42C5-L63 41 | // See: https://github.blog/2021-06-22-framework-building-open-graph-images/#some-performance-gotchas 42 | 43 | return << { 48 | // Image has already finished loading, let’s see if it worked 49 | if (img.complete) { 50 | // Image loaded and has presence 51 | if (img.naturalHeight !== 0) return; 52 | // Image failed, so it has no height 53 | throw new Error("Image failed to load"); 54 | } 55 | // Image hasn’t loaded yet, added an event listener to know when it does 56 | return new Promise((resolve, reject) => { 57 | img.addEventListener("load", resolve); 58 | img.addEventListener("error", reject); 59 | }); 60 | }) 61 | ]); 62 | JS; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open Graph Image Generator for Laravel 2 | 3 | > [!WARNING] 4 | > This package is not ready for general consumption 5 | 6 | ## Requirements 7 | 8 | Requires PHP 8.1+ and a Chrome/Chromium 65+ executable. 9 | 10 | ## Installation 11 | 12 | You can install the package via Composer: 13 | 14 | ```sh 15 | composer require aniftyco/laravel-og-image:dev-master 16 | ``` 17 | 18 | ## Usage 19 | 20 | There are multiple ways you can utilize this. 21 | 22 | ### `GET /og-image` Route 23 | 24 | This package registers a `GET` route to `/og-image` which accepts a varadic amount of parameters where only `template` is required and special. This tells the image generator what template to use relavent to `resources/views/og-image/`. 25 | 26 | Take this example: 27 | 28 | ``` 29 | https://example.com/og-image?template=post&title=You%20Can%20Just%20Do%20Things 30 | ``` 31 | 32 | It is telling the generator to use the `post` template and pass the `$title` property set to `You Can Just Do Things`. 33 | 34 | ### `og_image()` Helper 35 | 36 | Using the `og_image()` helper you have more control over what happens with the OG Images generated. This helper accepts a `template` and an array of data to pass to the view. 37 | 38 | #### Returning the OG image as a response: 39 | 40 | ```php 41 | Route::get('/{user}.png', function(User $user) { 42 | return og_image('user', compact('user')); 43 | }); 44 | ``` 45 | 46 | #### Saving the OG image to a disk: 47 | 48 | ```php 49 | $image = og_image('user', compact('user')); 50 | 51 | $image->storeAs('og-images', ['disk' => 's3']); 52 | ``` 53 | 54 | ### Writing templates with Blade 55 | 56 | Your images are generated using Blade templates, using the following example template in `resources/views/og-image/example.blade.php`: 57 | 58 | ```blade 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | @vite(['resources/css/app.css']) 67 | 68 | 69 | 70 |
71 |

72 | {{ $name }} 73 |

74 |
75 |
76 |

77 | {{ $text }} 78 |

79 |
80 | 81 | 82 | 83 | 84 | ``` 85 | 86 | Then if you make a request to `/og-image?template=example&name=OG%20Image%20Generator&text=The%20fastest%20way%20to%20create%20open%20graph%20images%20for%20Laravel!` you will get the following image: 87 | 88 | ![](.github/assets/example.png) 89 | 90 | ## Contributing 91 | 92 | Thank you for considering contributing to the Attachments for Laravel package! You can read the contribution guide [here](CONTRIBUTING.md). 93 | 94 | ## License 95 | 96 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 97 | -------------------------------------------------------------------------------- /src/Base64EncodedFile.php: -------------------------------------------------------------------------------- 1 | restoreToTemporary($encoded, true), $originalName, $mimeType, $error, $test); 22 | } 23 | 24 | /** 25 | * @param string $encoded 26 | * @param bool $strict 27 | * 28 | * @return string 29 | * @throws FileException 30 | */ 31 | private function restoreToTemporary(string $encoded, $strict = true): string 32 | { 33 | if (strpos($encoded, 'data:') === 0) { 34 | if (strpos($encoded, 'data://') !== 0) { 35 | $encoded = substr_replace($encoded, 'data://', 0, 5); 36 | } 37 | 38 | $source = @fopen($encoded, 'rb'); 39 | if ($source === false) { 40 | throw new FileException('Unable to decode strings as base64'); 41 | } 42 | 43 | $meta = stream_get_meta_data($source); 44 | 45 | if ($strict) { 46 | if (!isset($meta['base64']) || $meta['base64'] !== true) { 47 | throw new FileException('Unable to decode strings as base64'); 48 | } 49 | } 50 | 51 | if (false === $path = tempnam($directory = sys_get_temp_dir(), 'Base64EncodedFile')) { 52 | throw new FileException(sprintf('Unable to create a file into the "%s" directory', $path)); 53 | } 54 | 55 | if (null !== $extension = (MimeTypes::getDefault()->getExtensions($meta['mediatype'])[0] ?? null)) { 56 | $path .= '.' . $extension; 57 | } 58 | 59 | if (false === $target = @fopen($path, 'w+b')) { 60 | throw new FileException(sprintf('Unable to write the file "%s"', $path)); 61 | } 62 | 63 | if (false === @stream_copy_to_stream($source, $target)) { 64 | throw new FileException(sprintf('Unable to write the file "%s"', $path)); 65 | } 66 | 67 | if (false === @fclose($target)) { 68 | throw new FileException(sprintf('Unable to write the file "%s"', $path)); 69 | } 70 | 71 | if (false === @fclose($source)) { 72 | throw new FileException(sprintf('Unable to close data stream')); 73 | } 74 | 75 | return $path; 76 | } 77 | 78 | if (false === $decoded = base64_decode($encoded, $strict)) { 79 | throw new FileException('Unable to decode strings as base64'); 80 | } 81 | 82 | if (false === $path = tempnam($directory = sys_get_temp_dir(), 'Base64EncodedFile')) { 83 | throw new FileException(sprintf('Unable to create a file into the "%s" directory', $directory)); 84 | } 85 | 86 | if (false === file_put_contents($path, $decoded)) { 87 | throw new FileException(sprintf('Unable to write the file "%s"', $path)); 88 | } 89 | 90 | return $path; 91 | } 92 | 93 | public function __destruct() 94 | { 95 | if (file_exists($this->getPathname())) { 96 | unlink($this->getPathname()); 97 | } 98 | } 99 | } 100 | --------------------------------------------------------------------------------