├── LICENSE.txt ├── composer.json └── src ├── Cache.php ├── Console └── ClearCache.php ├── LaravelServiceProvider.php └── Middleware └── CacheResponse.php /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "silber/page-cache", 3 | "description": "Caches responses as static files on disk for lightning fast page loads.", 4 | "keywords": [ 5 | "laravel", 6 | "cache" 7 | ], 8 | "license": "MIT", 9 | "authors": [ 10 | { 11 | "name": "Joseph Silber", 12 | "email": "contact@josephsilber.com" 13 | } 14 | ], 15 | "autoload": { 16 | "psr-4": { 17 | "Silber\\PageCache\\": "src/" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Silber\\PageCache\\Tests\\": "tests" 23 | } 24 | }, 25 | "require": { 26 | "php": "^8.2", 27 | "illuminate/contracts": "^11.0|^12.0", 28 | "illuminate/filesystem": "^11.0|^12.0", 29 | "symfony/http-foundation": "^7.0" 30 | }, 31 | "require-dev": { 32 | "illuminate/container": "^11.0|^12.0", 33 | "laravel/pint": "^1.14", 34 | "mockery/mockery": "^1.6.9", 35 | "pestphp/pest": "^3.7", 36 | "phpunit/phpunit": "^11.0", 37 | "symfony/var-dumper": "^7.0" 38 | }, 39 | "suggest": { 40 | "illuminate/console": "Allows clearing the cache via artisan" 41 | }, 42 | "minimum-stability": "dev", 43 | "prefer-stable": true, 44 | "extra": { 45 | "laravel": { 46 | "providers": [ 47 | "Silber\\PageCache\\LaravelServiceProvider" 48 | ] 49 | } 50 | }, 51 | "config": { 52 | "allow-plugins": { 53 | "pestphp/pest-plugin": true 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Cache.php: -------------------------------------------------------------------------------- 1 | files = $files; 43 | } 44 | 45 | /** 46 | * Sets the container instance. 47 | * 48 | * @param \Illuminate\Contracts\Container\Container $container 49 | * @return $this 50 | */ 51 | public function setContainer(Container $container) 52 | { 53 | $this->container = $container; 54 | 55 | return $this; 56 | } 57 | 58 | /** 59 | * Sets the directory in which to store the cached pages. 60 | * 61 | * @param string $path 62 | * @return void 63 | */ 64 | public function setCachePath($path) 65 | { 66 | $this->cachePath = rtrim($path, '\/'); 67 | } 68 | 69 | /** 70 | * Gets the path to the cache directory. 71 | * 72 | * @param string ...$paths 73 | * @return string 74 | * 75 | * @throws \Exception 76 | */ 77 | public function getCachePath() 78 | { 79 | $base = $this->cachePath ? $this->cachePath : $this->getDefaultCachePath(); 80 | 81 | if (is_null($base)) { 82 | throw new Exception('Cache path not set.'); 83 | } 84 | 85 | return $this->join(array_merge([$base], func_get_args())); 86 | } 87 | 88 | /** 89 | * Join the given paths together by the system's separator. 90 | * 91 | * @param string[] $paths 92 | * @return string 93 | */ 94 | protected function join(array $paths) 95 | { 96 | $trimmed = array_map(function ($path) { 97 | return trim($path, '/'); 98 | }, $paths); 99 | 100 | return $this->matchRelativity( 101 | $paths[0], implode('/', array_filter($trimmed)) 102 | ); 103 | } 104 | 105 | /** 106 | * Makes the target path absolute if the source path is also absolute. 107 | * 108 | * @param string $source 109 | * @param string $target 110 | * @return string 111 | */ 112 | protected function matchRelativity($source, $target) 113 | { 114 | return $source[0] == '/' ? '/'.$target : $target; 115 | } 116 | 117 | /** 118 | * Caches the given response if we determine that it should be cache. 119 | * 120 | * @param \Symfony\Component\HttpFoundation\Request $request 121 | * @param \Symfony\Component\HttpFoundation\Response $response 122 | * @return $this 123 | */ 124 | public function cacheIfNeeded(Request $request, Response $response) 125 | { 126 | if ($this->shouldCache($request, $response)) { 127 | $this->cache($request, $response); 128 | } 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * Determines whether the given request/response pair should be cached. 135 | * 136 | * @param \Symfony\Component\HttpFoundation\Request $request 137 | * @param \Symfony\Component\HttpFoundation\Response $response 138 | * @return bool 139 | */ 140 | public function shouldCache(Request $request, Response $response) 141 | { 142 | return $request->isMethod('GET') && $response->getStatusCode() == 200; 143 | } 144 | 145 | /** 146 | * Cache the response to a file. 147 | * 148 | * @param \Symfony\Component\HttpFoundation\Request $request 149 | * @param \Symfony\Component\HttpFoundation\Response $response 150 | * @return void 151 | */ 152 | public function cache(Request $request, Response $response) 153 | { 154 | list($path, $file) = $this->getDirectoryAndFileNames($request, $response); 155 | 156 | $this->files->makeDirectory($path, 0775, true, true); 157 | 158 | $this->files->put( 159 | $this->join([$path, $file]), 160 | $response->getContent(), 161 | true 162 | ); 163 | } 164 | 165 | /** 166 | * Remove the cached file for the given slug. 167 | * 168 | * @param string $slug 169 | * @return bool 170 | */ 171 | public function forget($slug) 172 | { 173 | $deletedHtml = $this->files->delete($this->getCachePath($slug.'.html')); 174 | $deletedJson = $this->files->delete($this->getCachePath($slug.'.json')); 175 | $deletedXml = $this->files->delete($this->getCachePath($slug.'.xml')); 176 | 177 | return $deletedHtml || $deletedJson || $deletedXml; 178 | } 179 | 180 | /** 181 | * Clear the full cache directory, or a subdirectory. 182 | * 183 | * @param string|null 184 | * @return bool 185 | */ 186 | public function clear($path = null) 187 | { 188 | return $this->files->deleteDirectory($this->getCachePath($path), true); 189 | } 190 | 191 | /** 192 | * Get the names of the directory and file. 193 | * 194 | * @param \Illuminate\Http\Request $request 195 | * @param \Illuminate\Http\Response $response 196 | * @return array 197 | */ 198 | protected function getDirectoryAndFileNames($request, $response) 199 | { 200 | $segments = explode('/', trim($request->getPathInfo(), '/')); 201 | 202 | $filename = $this->aliasFilename(array_pop($segments)); 203 | $extension = $this->guessFileExtension($response); 204 | 205 | $file = "{$filename}.{$extension}"; 206 | 207 | return [$this->getCachePath(implode('/', $segments)), $file]; 208 | } 209 | 210 | /** 211 | * Alias the filename if necessary. 212 | * 213 | * @param string $filename 214 | * @return string 215 | */ 216 | protected function aliasFilename($filename) 217 | { 218 | return $filename ?: 'pc__index__pc'; 219 | } 220 | 221 | /** 222 | * Get the default path to the cache directory. 223 | * 224 | * @return string|null 225 | */ 226 | protected function getDefaultCachePath() 227 | { 228 | if ($this->container && $this->container->bound('path.public')) { 229 | return $this->container->make('path.public').'/page-cache'; 230 | } 231 | } 232 | 233 | /** 234 | * Guess the correct file extension for the given response. 235 | * 236 | * Currently, only JSON and HTML are supported. 237 | * 238 | * @return string 239 | */ 240 | protected function guessFileExtension($response) 241 | { 242 | $contentType = $response->headers->get('Content-Type'); 243 | 244 | if ($response instanceof JsonResponse || 245 | $contentType == 'application/json' 246 | ) { 247 | return 'json'; 248 | } 249 | 250 | if (in_array($contentType, ['text/xml', 'application/xml'])) { 251 | return 'xml'; 252 | } 253 | 254 | return 'html'; 255 | } 256 | 257 | } 258 | -------------------------------------------------------------------------------- /src/Console/ClearCache.php: -------------------------------------------------------------------------------- 1 | laravel->make(Cache::class); 32 | $recursive = $this->option('recursive'); 33 | $slug = $this->argument('slug'); 34 | 35 | if (!$slug) { 36 | $this->clear($cache); 37 | } else if ($recursive) { 38 | $this->clear($cache, $slug); 39 | } else { 40 | $this->forget($cache, $slug); 41 | } 42 | } 43 | 44 | /** 45 | * Remove the cached file for the given slug. 46 | * 47 | * @param \Silber\PageCache\Cache $cache 48 | * @param string $slug 49 | * @return void 50 | */ 51 | public function forget(Cache $cache, $slug) 52 | { 53 | if ($cache->forget($slug)) { 54 | $this->info("Page cache cleared for \"{$slug}\""); 55 | } else { 56 | $this->info("No page cache found for \"{$slug}\""); 57 | } 58 | } 59 | 60 | /** 61 | * Clear the full page cache. 62 | * 63 | * @param \Silber\PageCache\Cache $cache 64 | * @param string|null $path 65 | * @return void 66 | */ 67 | public function clear(Cache $cache, $path = null) 68 | { 69 | if ($cache->clear($path)) { 70 | $this->info('Page cache cleared at '.$cache->getCachePath($path)); 71 | } else { 72 | $this->warn('Page cache not cleared at '.$cache->getCachePath($path)); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/LaravelServiceProvider.php: -------------------------------------------------------------------------------- 1 | commands(ClearCache::class); 18 | 19 | $this->app->singleton(Cache::class, function () { 20 | $instance = new Cache($this->app->make('files')); 21 | 22 | return $instance->setContainer($this->app); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Middleware/CacheResponse.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 27 | } 28 | 29 | /** 30 | * Handle an incoming request. 31 | * 32 | * @param \Symfony\Component\HttpFoundation\Request $request 33 | * @param \Closure $next 34 | * @return mixed 35 | */ 36 | public function handle(Request $request, Closure $next) 37 | { 38 | $response = $next($request); 39 | 40 | if ($this->shouldCache($request, $response)) { 41 | $this->cache->cache($request, $response); 42 | } 43 | 44 | return $response; 45 | } 46 | 47 | /** 48 | * Determines whether the given request/response pair should be cached. 49 | * 50 | * @param \Symfony\Component\HttpFoundation\Request $request 51 | * @param \Symfony\Component\HttpFoundation\Response $response 52 | * @return bool 53 | */ 54 | protected function shouldCache(Request $request, Response $response) 55 | { 56 | return $request->isMethod('GET') && $response->getStatusCode() == 200; 57 | } 58 | } 59 | --------------------------------------------------------------------------------