├── .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 |
74 |
75 |
76 |
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 | 
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 |
--------------------------------------------------------------------------------