├── .php_cs.dist.php ├── .stubs.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADING.md ├── composer.json ├── config └── feed.php ├── resources ├── dist │ ├── atom.xsl │ └── style.css └── views │ ├── atom.blade.php │ ├── json.blade.php │ ├── links.blade.php │ └── rss.blade.php └── src ├── Components └── FeedLinks.php ├── Exceptions ├── InvalidConfiguration.php └── InvalidFeedItem.php ├── Feed.php ├── FeedItem.php ├── FeedServiceProvider.php ├── Feedable.php ├── Helpers ├── Cdata.php ├── ConfigurationValidator.php ├── FeedContentType.php ├── Path.php └── ResolveFeedItems.php └── Http └── FeedController.php /.php_cs.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | __DIR__ . '/src', 6 | __DIR__ . '/tests', 7 | ]) 8 | ->name('*.php') 9 | ->notName('*.blade.php') 10 | ->ignoreDotFiles(true) 11 | ->ignoreVCS(true); 12 | 13 | return (new PhpCsFixer\Config()) 14 | ->setRules([ 15 | '@PSR12' => true, 16 | 'array_syntax' => ['syntax' => 'short'], 17 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 18 | 'no_unused_imports' => true, 19 | 'not_operator_with_successor_space' => true, 20 | 'trailing_comma_in_multiline' => true, 21 | 'phpdoc_scalar' => true, 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'class_attributes_separation' => [ 30 | 'elements' => [ 31 | 'method' => 'one', 32 | ], 33 | ], 34 | 'method_argument_space' => [ 35 | 'on_multiline' => 'ensure_fully_multiline', 36 | 'keep_multiple_spaces_after_comma' => true, 37 | ], 38 | 'single_trait_insert_per_statement' => true, 39 | ]) 40 | ->setFinder($finder); 41 | -------------------------------------------------------------------------------- /.stubs.php: -------------------------------------------------------------------------------- 1 | 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | Logo for laravel-feed 6 | 7 | 8 | 9 |

Generate RSS feeds in a Laravel app

10 | 11 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/spatie/laravel-feed.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-feed) 12 | [![MIT Licensed](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 13 | [![run-tests](https://github.com/spatie/laravel-feed/actions/workflows/run-tests.yml/badge.svg)](https://github.com/spatie/laravel-feed/actions/workflows/run-tests.yml) 14 | [![Total Downloads](https://img.shields.io/packagist/dt/spatie/laravel-feed.svg?style=flat-square)](https://packagist.org/packages/spatie/laravel-feed) 15 | 16 |
17 | 18 | This package provides an easy way to generate a feed for your Laravel application. Supported formats are [RSS](http://www.whatisrss.com/), [Atom](https://en.wikipedia.org/wiki/Atom_(standard)), and [JSON](https://jsonfeed.org). There's almost no coding required on your part. Just follow the installation instructions, update your config file, and you're good to go. 19 | 20 | Spatie is a webdesign agency based in Antwerp, Belgium. You'll find an overview of all our open source projects [on our website](https://spatie.be/opensource). 21 | 22 | ## Support us 23 | 24 | [](https://spatie.be/github-ad-click/laravel-feed) 25 | 26 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 27 | 28 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 29 | 30 | ## Installation 31 | 32 | You can install the package via composer: 33 | 34 | ``` bash 35 | composer require spatie/laravel-feed 36 | ``` 37 | 38 | Register the routes the feeds will be displayed on using the `feeds`-macro. 39 | 40 | ```php 41 | // In routes/web.php 42 | Route::feeds(); 43 | ``` 44 | 45 | Optionally, you can pass a string as a first argument of the macro. The string will be used as a URL prefix for all configured feeds. 46 | 47 | Next, you must publish the config file: 48 | 49 | ```bash 50 | php artisan feed:install 51 | ``` 52 | 53 | Here's what that looks like: 54 | 55 | ```php 56 | return [ 57 | 'feeds' => [ 58 | 'main' => [ 59 | /* 60 | * Here you can specify which class and method will return 61 | * the items that should appear in the feed. For example: 62 | * [App\Model::class, 'getAllFeedItems'] 63 | * 64 | * You can also pass an argument to that method. Note that their key must be the name of the parameter: * 65 | * [App\Model::class, 'getAllFeedItems', 'parameterName' => 'argument'] 66 | */ 67 | 'items' => '', 68 | 69 | /* 70 | * The feed will be available on this url. 71 | */ 72 | 'url' => '', 73 | 74 | 'title' => 'My feed', 75 | 'description' => 'The description of the feed.', 76 | 'language' => 'en-US', 77 | 78 | /* 79 | * The image to display for the feed. For Atom feeds, this is displayed as 80 | * a banner/logo; for RSS and JSON feeds, it's displayed as an icon. 81 | * An empty value omits the image attribute from the feed. 82 | */ 83 | 'image' => '', 84 | 85 | /* 86 | * The format of the feed. Acceptable values are 'rss', 'atom', or 'json'. 87 | */ 88 | 'format' => 'atom', 89 | 90 | /* 91 | * The view that will render the feed. 92 | */ 93 | 'view' => 'feed::atom', 94 | 95 | /* 96 | * The mime type to be used in the tag. Set to an empty string to automatically 97 | * determine the correct value. 98 | */ 99 | 'type' => '', 100 | 101 | /* 102 | * The content type for the feed response. Set to an empty string to automatically 103 | * determine the correct value. 104 | */ 105 | 'contentType' => '', 106 | ], 107 | ], 108 | ]; 109 | ``` 110 | 111 | Optionally you can publish the view files: 112 | 113 | ```bash 114 | php artisan vendor:publish --provider="Spatie\Feed\FeedServiceProvider" --tag="feed-views" 115 | ``` 116 | 117 | ## Usage 118 | 119 | Imagine you have a model named `NewsItem` that contains records that you want to have displayed in the feed. 120 | 121 | First you must implement the `Feedable` interface on that model. `Feedable` expects one method: `toFeedItem`, which should return a `FeedItem` instance. 122 | 123 | ```php 124 | // app/NewsItem.php 125 | 126 | use Illuminate\Database\Eloquent\Model; 127 | use Spatie\Feed\Feedable; 128 | use Spatie\Feed\FeedItem; 129 | 130 | class NewsItem extends Model implements Feedable 131 | { 132 | public function toFeedItem(): FeedItem 133 | { 134 | return FeedItem::create() 135 | ->id($this->id) 136 | ->title($this->title) 137 | ->summary($this->summary) 138 | ->updated($this->updated_at) 139 | ->link($this->link) 140 | ->authorName($this->author) 141 | ->authorEmail($this->authorEmail); 142 | } 143 | } 144 | ``` 145 | 146 | If you prefer, returning an associative array with the necessary keys will do the trick too. 147 | 148 | ```php 149 | // app/NewsItem.php 150 | 151 | use Illuminate\Database\Eloquent\Model; 152 | use Spatie\Feed\Feedable; 153 | use Spatie\Feed\FeedItem; 154 | 155 | class NewsItem extends Model implements Feedable 156 | { 157 | public function toFeedItem(): FeedItem 158 | { 159 | return FeedItem::create([ 160 | 'id' => $this->id, 161 | 'title' => $this->title, 162 | 'summary' => $this->summary, 163 | 'updated' => $this->updated_at, 164 | 'link' => $this->link, 165 | 'authorName' => $this->authorName, 166 | ]); 167 | } 168 | } 169 | ``` 170 | 171 | Next, you'll have to create a method that will return all the items that must be displayed in 172 | the feed. You can name that method anything you like and you can do any query you want. 173 | 174 | ```php 175 | // app/NewsItem.php 176 | 177 | public static function getFeedItems() 178 | { 179 | return NewsItem::all(); 180 | } 181 | ``` 182 | 183 | Finally, you have to put the name of your class and the url where you want the feed to rendered 184 | in the config file: 185 | 186 | ```php 187 | // config/feed.php 188 | 189 | return [ 190 | 191 | 'feeds' => [ 192 | 'news' => [ 193 | /* 194 | * Here you can specify which class and method will return 195 | * the items that should appear in the feed. For example: 196 | * 'App\Model@getAllFeedItems' 197 | * or 198 | * ['App\Model', 'getAllFeedItems'] 199 | * 200 | * You can also pass an argument to that method. Note that their key must be the name of the parameter: * 201 | * ['App\Model@getAllFeedItems', 'parameterName' => 'argument'] 202 | * or 203 | * ['App\Model', 'getAllFeedItems', 'parameterName' => 'argument'] 204 | */ 205 | 'items' => 'App\NewsItem@getFeedItems', 206 | 207 | /* 208 | * The feed will be available on this url. 209 | */ 210 | 'url' => '/feed', 211 | 212 | 'title' => 'All newsitems on mysite.com', 213 | 214 | /* 215 | * The format of the feed. Acceptable values are 'rss', 'atom', or 'json'. 216 | */ 217 | 'format' => 'atom', 218 | 219 | /* 220 | * Custom view for the items. 221 | * 222 | * Defaults to feed::feed if not present. 223 | */ 224 | 'view' => 'feed::feed', 225 | ], 226 | ], 227 | 228 | ]; 229 | ``` 230 | 231 | The `items` key must point to a method that returns one of the following: 232 | 233 | - An array or collection of `Feedable`s 234 | - An array or collection of `FeedItem`s 235 | - An array or collection of arrays containing feed item values 236 | 237 | ### Customizing your feed views 238 | 239 | This package provides, out of the box, the `feed::feed` view that displays your feeds details. 240 | 241 | However, you could use a custom view per feed by providing a `view` key inside of your feed configuration. 242 | 243 | In the following example, we're using the previous `News` feed with a custom `feeds.news` view (located on `resources/views/feeds/news.blade.php`): 244 | 245 | ```php 246 | // config/feed.php 247 | 248 | return [ 249 | 250 | 'feeds' => [ 251 | 'news' => [ 252 | 'items' => ['App\NewsItem', 'getFeedItems'], 253 | 254 | 'url' => '/feed', 255 | 256 | 'title' => 'All newsitems on mysite.com', 257 | 258 | /* 259 | * The format of the feed. Acceptable values are 'rss', 'atom', or 'json'. 260 | */ 261 | 'format' => 'atom', 262 | 263 | /* 264 | * Custom view for the items. 265 | * 266 | * Defaults to feed::feed if not present. 267 | */ 268 | 'view' => 'feeds.news', 269 | ], 270 | ], 271 | 272 | ]; 273 | ``` 274 | 275 | ### Automatically generate feed links 276 | 277 | To discover a feed, feed readers are looking for a tag in the head section of your html documents that looks like this: 278 | 279 | ```html 280 | 281 | ``` 282 | 283 | You can add this to your `` through a partial view. 284 | 285 | ```php 286 | @include('feed::links') 287 | ``` 288 | 289 | As an alternative you can use this blade component: 290 | 291 | ```html 292 | 293 | ``` 294 | 295 | ## Testing 296 | 297 | ```bash 298 | composer test 299 | ``` 300 | 301 | ## Changelog 302 | 303 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 304 | 305 | ## Contributing 306 | 307 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 308 | 309 | ## Security Vulnerabilities 310 | 311 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 312 | 313 | ## Credits 314 | 315 | - [Jolita Grazyte](https://github.com/JolitaGrazyte) 316 | - [Freek Van der Herten](https://github.com/freekmurze) 317 | - [Sebastian De Deyne](https://github.com/sebastiandedeyne) 318 | - [All Contributors](../../contributors) 319 | 320 | ## License 321 | 322 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 323 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | This guide contains the steps to follow for upgrading `laravel-feed` versions. 4 | 5 | ## Upgrading from v4 to v4.3 6 | 7 | v4.3 added support for styled RSS feeds. To enable them issue this command 8 | 9 | ```bash 10 | php artisan feed:install 11 | ``` 12 | 13 | This command will published the required assets. 14 | 15 | ## Upgrading from v3 to v4 16 | 17 | When upgrading from v3 to v4, there are a number of changes required; they primarily affect the configuration file, however 18 | there are also some minor changes required to the return value of the `toFeedItem()` method implemented on your models. 19 | 20 | ### Configuration file changes 21 | 22 | Make the following additions and changes to the `config/feed.php` configuration file. 23 | 24 | - add `image` to each feed as either a url to an image for the feed or an empty string: 25 | 26 | ```php 27 | 'image' => '', 28 | ``` 29 | 30 | - add `format` to each feed with a valid value (`atom`, `json`, or `rss`): 31 | 32 | ```php 33 | 'format' => 'atom', 34 | ``` 35 | 36 | - add `contentType` to each feed _(an empty value forces auto-detect)_: 37 | 38 | ```php 39 | 'contentType' => '', 40 | ``` 41 | 42 | - update the `view` key in each feed to an existing view that is not `feed::feed`: 43 | 44 | ```php 45 | 'view' => 'feed::atom', 46 | ``` 47 | 48 | - update the `type` key in each feed to an empty value unless you're sure you want to keep the existing value _(an empty value enables auto-detect)_: 49 | 50 | ```php 51 | 'type' => '', 52 | ``` 53 | 54 | ### `toFeedItem()` return value changes 55 | 56 | The `author` property is no longer used. Instead, return an `authorName` property and optionally an `authorEmail` property. 57 | 58 | If you decide to take advantage of the new `jsonfeed.org` support, you may return an `image` property that associates an image with the feed item. 59 | 60 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/laravel-feed", 3 | "description": "Generate rss feeds", 4 | "keywords": [ 5 | "spatie", 6 | "rss", 7 | "laravel", 8 | "laravel-feed" 9 | ], 10 | "homepage": "https://github.com/spatie/laravel-feed", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Jolita Grazyte", 15 | "email": "jolita@spatie.be", 16 | "homepage": "https://spatie.be", 17 | "role": "Developer" 18 | }, 19 | { 20 | "name": "Freek Van der Herten", 21 | "email": "freek@spatie.be", 22 | "homepage": "https://spatie.be", 23 | "role": "Developer" 24 | }, 25 | { 26 | "name": "Sebastian De Deyne", 27 | "email": "sebastian@spatie.be", 28 | "homepage": "https://spatie.be", 29 | "role": "Developer" 30 | }, 31 | { 32 | "name": "Patrick Organ", 33 | "homepage": "https://github.com/patinthehat", 34 | "role": "Developer" 35 | } 36 | ], 37 | "require": { 38 | "php": "^8.2", 39 | "illuminate/support": "^10.0|^11.0|^12.0", 40 | "illuminate/http": "^10.0|^11.0|^12.0", 41 | "illuminate/contracts": "^10.0|^11.0|^12.0", 42 | "spatie/laravel-package-tools": "^1.15" 43 | }, 44 | "require-dev": { 45 | "orchestra/testbench": "^8.0|^9.0|^10.0", 46 | "spatie/test-time": "^1.2", 47 | "pestphp/pest": "^2.0|^3.0", 48 | "spatie/pest-plugin-snapshots": "^2.0" 49 | }, 50 | "autoload": { 51 | "psr-4": { 52 | "Spatie\\Feed\\": "src" 53 | } 54 | }, 55 | "autoload-dev": { 56 | "psr-4": { 57 | "Spatie\\Feed\\Test\\": "tests" 58 | } 59 | }, 60 | "scripts": { 61 | "test": "vendor/bin/pest", 62 | "test-coverage": "vendor/bin/pest --coverage-html coverage" 63 | }, 64 | "extra": { 65 | "laravel": { 66 | "providers": [ 67 | "Spatie\\Feed\\FeedServiceProvider" 68 | ] 69 | } 70 | }, 71 | "minimum-stability": "dev", 72 | "prefer-stable": true, 73 | "config": { 74 | "allow-plugins": { 75 | "pestphp/pest-plugin": true 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /config/feed.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'main' => [ 6 | /* 7 | * Here you can specify which class and method will return 8 | * the items that should appear in the feed. For example: 9 | * [App\Model::class, 'getAllFeedItems'] 10 | * 11 | * You can also pass an argument to that method. Note that their key must be the name of the parameter: 12 | * [App\Model::class, 'getAllFeedItems', 'parameterName' => 'argument'] 13 | */ 14 | 'items' => '', 15 | 16 | /* 17 | * The feed will be available on this url. 18 | */ 19 | 'url' => '', 20 | 21 | 'title' => 'My feed', 22 | 'description' => 'The description of the feed.', 23 | 'language' => 'en-US', 24 | 25 | /* 26 | * The image to display for the feed. For Atom feeds, this is displayed as 27 | * a banner/logo; for RSS and JSON feeds, it's displayed as an icon. 28 | * An empty value omits the image attribute from the feed. 29 | */ 30 | 'image' => '', 31 | 32 | /* 33 | * The format of the feed. Acceptable values are 'rss', 'atom', or 'json'. 34 | */ 35 | 'format' => 'atom', 36 | 37 | /* 38 | * The view that will render the feed. 39 | */ 40 | 'view' => 'feed::atom', 41 | 42 | /* 43 | * The mime type to be used in the tag. Set to an empty string to automatically 44 | * determine the correct value. 45 | */ 46 | 'type' => '', 47 | 48 | /* 49 | * The content type for the feed response. Set to an empty string to automatically 50 | * determine the correct value. 51 | */ 52 | 'contentType' => '', 53 | ], 54 | ], 55 | ]; 56 | -------------------------------------------------------------------------------- /resources/dist/atom.xsl: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | RSS Feed | <xsl:value-of select="/atom:feed/atom:title"/> 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

19 | 20 | 24 | 25 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 38 | 40 | 42 | 43 | 46 | 49 | 50 | RSS Feed 51 |

52 |

53 |

54 | 55 |

56 |
57 | 58 |
59 | 67 | 68 |
69 | 70 |
71 | 72 |
73 | Published on 74 | by 75 |
76 |
77 |
78 |
79 | 80 | 81 |
82 |
83 | -------------------------------------------------------------------------------- /resources/dist/style.css: -------------------------------------------------------------------------------- 1 | .layout-content { 2 | max-width: 640px; 3 | margin-left: auto; 4 | margin-right: auto; 5 | 6 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 7 | } 8 | 9 | hr { 10 | margin: 2rem 0; 11 | } 12 | 13 | .post { 14 | margin-bottom: 2rem; 15 | } 16 | 17 | .post .title { 18 | font-size: 1.5rem; 19 | line-height: 2rem; 20 | margin-bottom: 1rem; 21 | } 22 | 23 | .post .summary { 24 | font-size: 1rem; 25 | line-height: 1.5rem; 26 | margin-bottom: 1rem; 27 | } 28 | 29 | .post .published-info { 30 | font-size: 0.75rem; 31 | line-height: 1rem; 32 | color: #666; 33 | } 34 | 35 | img { 36 | max-width: 100%; 37 | } 38 | -------------------------------------------------------------------------------- /resources/views/atom.blade.php: -------------------------------------------------------------------------------- 1 | ` won't get parsed as short tags */ 3 | ''.PHP_EOL. 4 | '' 5 | ?> 6 | 7 | 8 | @foreach($meta as $key => $metaItem) 9 | @if($key === 'link') 10 | <{{ $key }} href="{{ url($metaItem) }}" rel="self"> 11 | @elseif($key === 'title') 12 | <{{ $key }}>{!! \Spatie\Feed\Helpers\Cdata::out($metaItem) !!} 13 | @elseif($key === 'description') 14 | {{ $metaItem }} 15 | @elseif($key === 'language') 16 | @elseif($key === 'image') 17 | @if(!empty($metaItem)) 18 | {!! $metaItem !!} 19 | @else 20 | 21 | @endif 22 | @else 23 | <{{ $key }}>{{ $metaItem }} 24 | @endif 25 | @endforeach 26 | @foreach($items as $item) 27 | 28 | {!! \Spatie\Feed\Helpers\Cdata::out($item->title) !!} 29 | 30 | {{ url($item->id) }} 31 | 32 | {!! \Spatie\Feed\Helpers\Cdata::out($item->authorName) !!} 33 | @if(!empty($item->authorEmail)) 34 | {!! \Spatie\Feed\Helpers\Cdata::out($item->authorEmail) !!} 35 | 36 | @endif 37 | 38 | 39 | {!! \Spatie\Feed\Helpers\Cdata::out($item->summary) !!} 40 | 41 | @if($item->__isset('enclosure')) 42 | 43 | @endif 44 | @foreach($item->category as $category) 45 | 46 | @endforeach 47 | {{ $item->timestamp() }} 48 | 49 | @endforeach 50 | 51 | -------------------------------------------------------------------------------- /resources/views/json.blade.php: -------------------------------------------------------------------------------- 1 | { 2 | "version": "https://jsonfeed.org/version/1.1", 3 | "title": "{{ $meta['title'] }}", 4 | @if(!empty($meta['description'])) 5 | "description": "{{ $meta['description'] }}", 6 | @endif 7 | "home_page_url": "{{ config('app.url') }}", 8 | "feed_url": "{{ url($meta['link']) }}", 9 | "language": "{{ $meta['language'] }}", 10 | @if(!empty($meta['image'])) 11 | "icon": "{{ $meta['image'] }}", 12 | @endif 13 | "authors": [@foreach($items->unique('authorName') as $item){ 14 | "name": "{{ $item->authorName }}" 15 | }@if(! $loop->last),@endif 16 | @endforeach 17 | 18 | ], 19 | "items": [@foreach($items as $item){ 20 | "id": "{{ url($item->id) }}", 21 | "title": {!! json_encode($item->title) !!}, 22 | "url": "{{ url($item->link) }}", 23 | "content_html": {!! json_encode($item->summary) !!}, 24 | "summary": {!! json_encode($item->summary) !!}, 25 | "date_published": "{{ $item->timestamp() }}", 26 | "date_modified": "{{ $item->timestamp() }}", 27 | "authors": [{ "name": {!! json_encode($item->authorName) !!} }], 28 | @if($item->__isset('image')) 29 | "image": "{{ url($item->image) }}", 30 | @endif 31 | @if($item->__isset('enclosure')) 32 | "attachments": [ 33 | { 34 | "url": "{{ url($item->enclosure) }}", 35 | "mime_type": "{{ $item->enclosureType }}", 36 | "size_in_bytes": {{ $item->enclosureLength }} 37 | } 38 | ], 39 | @endif 40 | "tags": [ {!! implode(',', array_map(fn($c) => '"'.$c.'"', $item->category)) !!} ] 41 | }@if(! $loop->last), 42 | @endif 43 | @endforeach 44 | 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /resources/views/links.blade.php: -------------------------------------------------------------------------------- 1 | @foreach($feeds as $name => $feed) 2 | 3 | @endforeach 4 | -------------------------------------------------------------------------------- /resources/views/rss.blade.php: -------------------------------------------------------------------------------- 1 | ` won't get parsed as short tags */ 3 | ''.PHP_EOL 4 | ?> 5 | 6 | 7 | 8 | {!! \Spatie\Feed\Helpers\Cdata::out($meta['title'] ) !!} 9 | {!! \Spatie\Feed\Helpers\Cdata::out(url($meta['link']) ) !!} 10 | @if(!empty($meta['image'])) 11 | 12 | {{ $meta['image'] }} 13 | {!! \Spatie\Feed\Helpers\Cdata::out($meta['title'] ) !!} 14 | {!! \Spatie\Feed\Helpers\Cdata::out(url($meta['link']) ) !!} 15 | 16 | @endif 17 | {!! \Spatie\Feed\Helpers\Cdata::out($meta['description'] ) !!} 18 | {{ $meta['language'] }} 19 | {{ $meta['updated'] }} 20 | 21 | @foreach($items as $item) 22 | 23 | {!! \Spatie\Feed\Helpers\Cdata::out($item->title) !!} 24 | {{ url($item->link) }} 25 | {!! \Spatie\Feed\Helpers\Cdata::out($item->summary) !!} 26 | {!! \Spatie\Feed\Helpers\Cdata::out($item->authorName.(empty($item->authorEmail)?'':' <'.$item->authorEmail.'>')) !!} 27 | {{ url($item->id) }} 28 | {{ $item->timestamp() }} 29 | @foreach($item->category as $category) 30 | {{ $category }} 31 | @endforeach 32 | 33 | @endforeach 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Components/FeedLinks.php: -------------------------------------------------------------------------------- 1 | withSubject($subject); 15 | } 16 | 17 | public static function notAFeedItem($subject): static 18 | { 19 | return (new static('`toFeedItem` should return an instance of `Spatie\Feed\Feedable`'))->withSubject($subject); 20 | } 21 | 22 | public static function missingField(FeedItem $subject, $field): static 23 | { 24 | return (new static("Field `{$field}` is required"))->withSubject($subject); 25 | } 26 | 27 | protected function withSubject($subject): static 28 | { 29 | $this->subject = $subject; 30 | 31 | return $this; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Feed.php: -------------------------------------------------------------------------------- 1 | url = $url ?? request()->url(); 27 | 28 | $this->feedItems = $this->items->map(fn ($feedable) => $this->castToFeedItem($feedable)); 29 | } 30 | 31 | public function toResponse($request): Response 32 | { 33 | $meta = [ 34 | 'id' => url($this->url), 35 | 'link' => url($this->url), 36 | 'title' => $this->title, 37 | 'image' => $this->image, 38 | 'description' => $this->description, 39 | 'language' => $this->language, 40 | 'updated' => $this->lastUpdated(), 41 | ]; 42 | 43 | $contents = view($this->view, [ 44 | 'meta' => $meta, 45 | 'items' => $this->feedItems, 46 | 'xsl' => $this->xsl, 47 | ]); 48 | 49 | return new Response($contents, 200, [ 50 | 'Content-Type' => FeedContentType::forResponse($this->format) . ';charset=UTF-8', 51 | ]); 52 | } 53 | 54 | public function format(): string 55 | { 56 | return $this->format; 57 | } 58 | 59 | protected function castToFeedItem(array | FeedItem | Feedable $feedable): FeedItem 60 | { 61 | if (is_array($feedable)) { 62 | $feedable = new FeedItem($feedable); 63 | } 64 | 65 | if ($feedable instanceof FeedItem) { 66 | $feedable->feed = $this; 67 | 68 | $feedable->validate(); 69 | 70 | return $feedable; 71 | } 72 | 73 | if (! $feedable instanceof Feedable) { 74 | throw InvalidFeedItem::notFeedable($feedable); 75 | } 76 | 77 | $feedItem = $feedable->toFeedItem(); 78 | 79 | if (! $feedItem instanceof FeedItem) { 80 | throw InvalidFeedItem::notAFeedItem($feedItem); 81 | } 82 | 83 | $feedItem->feed = $this; 84 | 85 | $feedItem->validate(); 86 | 87 | return $feedItem; 88 | } 89 | 90 | protected function lastUpdated(): string 91 | { 92 | if ($this->feedItems->isEmpty()) { 93 | return ''; 94 | } 95 | 96 | $updatedAt = $this->feedItems 97 | ->sortBy(fn ($feedItem) => $feedItem->updated) 98 | ->last()->updated; 99 | 100 | 101 | return $this->format === 'rss' 102 | ? $updatedAt->toRssString() 103 | : $updatedAt->toRfc3339String(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/FeedItem.php: -------------------------------------------------------------------------------- 1 | $value) { 40 | if ($key === 'category') { 41 | $this->category = (array) $value; 42 | 43 | continue; 44 | } 45 | 46 | $this->$key = $value; 47 | } 48 | } 49 | 50 | public static function create(array $data = []): static 51 | { 52 | return new static($data); 53 | } 54 | 55 | public function id(string | int $id): self 56 | { 57 | $this->id = (string) $id; 58 | 59 | return $this; 60 | } 61 | 62 | public function title(string $title): self 63 | { 64 | $this->title = $title; 65 | 66 | return $this; 67 | } 68 | 69 | public function updated(CarbonInterface $updated): self 70 | { 71 | $this->updated = $updated; 72 | 73 | return $this; 74 | } 75 | 76 | public function summary(string $summary): self 77 | { 78 | $this->summary = $summary; 79 | 80 | return $this; 81 | } 82 | 83 | public function link(string $link): self 84 | { 85 | $this->link = $link; 86 | 87 | return $this; 88 | } 89 | 90 | public function enclosure(string $enclosure): self 91 | { 92 | $this->enclosure = $enclosure; 93 | 94 | return $this; 95 | } 96 | 97 | public function enclosureLength(int $enclosureLength): self 98 | { 99 | $this->enclosureLength = $enclosureLength; 100 | 101 | return $this; 102 | } 103 | 104 | public function enclosureType(string $enclosureType): self 105 | { 106 | $this->enclosureType = $enclosureType; 107 | 108 | return $this; 109 | } 110 | 111 | public function image(string $image): self 112 | { 113 | $this->image = $image; 114 | 115 | return $this; 116 | } 117 | 118 | public function authorName(string $authorName): self 119 | { 120 | $this->authorName = $authorName; 121 | 122 | return $this; 123 | } 124 | 125 | public function authorEmail(string $authorEmail): self 126 | { 127 | $this->authorEmail = $authorEmail; 128 | 129 | return $this; 130 | } 131 | 132 | public function category(string ...$category): self 133 | { 134 | $this->category = $category; 135 | 136 | return $this; 137 | } 138 | 139 | public function timestamp(): string 140 | { 141 | if ($this->feed->format() === 'rss') { 142 | return $this->updated->toRssString(); 143 | } 144 | 145 | return $this->updated->toRfc3339String(); 146 | } 147 | 148 | public function validate(): void 149 | { 150 | $requiredFields = ['id', 'title', 'updated', 'summary', 'link', 'authorName']; 151 | 152 | foreach ($requiredFields as $requiredField) { 153 | if (is_null($this->$requiredField)) { 154 | throw InvalidFeedItem::missingField($this, $requiredField); 155 | } 156 | } 157 | } 158 | 159 | public function __get($key) 160 | { 161 | if (! isset($this->$key)) { 162 | throw new Exception("Property `{$key}` doesn't exist"); 163 | } 164 | 165 | return $this->$key; 166 | } 167 | 168 | public function __isset($key) 169 | { 170 | return isset($this->$key); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/FeedServiceProvider.php: -------------------------------------------------------------------------------- 1 | name('laravel-feed') 20 | ->hasConfigFile() 21 | ->hasViews() 22 | ->hasViewComposer('feed::links', function ($view) { 23 | $view->with('feeds', $this->feeds()); 24 | }) 25 | ->hasViewComponent('', FeedLinks::class) 26 | ->hasInstallCommand(function (InstallCommand $command) { 27 | $command 28 | ->publishAssets() 29 | ->publishConfigFile() 30 | ->askToStarRepoOnGitHub('spatie/laravel-feed'); 31 | }) 32 | ->hasAssets(); 33 | } 34 | 35 | public function packageRegistered() 36 | { 37 | $this->registerRouteMacro(); 38 | } 39 | 40 | public function packageBooted() 41 | { 42 | if (! app()->runningUnitTests()) { 43 | ConfigurationValidator::validate(); 44 | } 45 | } 46 | 47 | protected function registerRouteMacro(): void 48 | { 49 | $router = $this->app['router']; 50 | 51 | $router->macro('feeds', function ($baseUrl = '') use ($router) { 52 | foreach (config('feed.feeds') as $name => $configuration) { 53 | $url = Path::merge($baseUrl, $configuration['url']); 54 | 55 | $router->get($url, '\\'.FeedController::class)->name("feeds.{$name}"); 56 | } 57 | }); 58 | } 59 | 60 | protected function feeds(): Collection 61 | { 62 | return collect(config('feed.feeds')); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Feedable.php: -------------------------------------------------------------------------------- 1 | '', // CDATA cannot be nested. 12 | ']]>' => ']]>', // CDEnd needs to be escaped. 13 | ]; 14 | 15 | return ''; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Helpers/ConfigurationValidator.php: -------------------------------------------------------------------------------- 1 | $config) { 15 | if (! in_array($config['format'], ['atom', 'json', 'rss'])) { 16 | throw InvalidConfiguration::unrecognizedFormat($name, $config['format']); 17 | } 18 | 19 | if (! View::exists($config['view'] ?? 'feed::atom')) { 20 | throw InvalidConfiguration::invalidView($name); 21 | } 22 | } 23 | } 24 | 25 | public static function validateResolver(string $feedName, $resolver): void 26 | { 27 | if (! self::feedItemsResolverIsValid($resolver)) { 28 | throw InvalidConfiguration::invalidItemsValue($feedName); 29 | } 30 | } 31 | 32 | protected static function feedItemsResolverIsValid($resolver): bool 33 | { 34 | if (! is_string($resolver) && ! is_array($resolver)) { 35 | return false; 36 | } 37 | 38 | if (is_string($resolver) && ! str_contains($resolver, '@')) { 39 | return false; 40 | } 41 | 42 | if (is_array($resolver) && count($resolver) < 2) { 43 | return false; 44 | } 45 | 46 | return true; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Helpers/FeedContentType.php: -------------------------------------------------------------------------------- 1 | ['response' => 'application/xml', 'link' => 'application/atom+xml'], 9 | 'json' => ['response' => 'application/json', 'link' => 'application/feed+json'], 10 | 'rss' => ['response' => 'application/xml', 'link' => 'application/rss+xml'], 11 | ]; 12 | 13 | public static array $defaults = [ 14 | 'response' => 'application/xml', 15 | 'link' => 'application/atom+xml', 16 | ]; 17 | 18 | public static function forResponse(string $feedFormat): string 19 | { 20 | $contentType = config('feed::contentType'); 21 | $mappedType = self::$typeMap[$feedFormat]['response'] ?? self::$defaults['response']; 22 | 23 | return empty($contentType) 24 | ? $mappedType 25 | : $contentType; 26 | } 27 | 28 | public static function forLink(string $feedFormat): string 29 | { 30 | $type = config('feed::type'); 31 | $mappedType = self::$typeMap[$feedFormat]['link'] ?? self::$defaults['link']; 32 | 33 | return empty($type) 34 | ? $mappedType 35 | : $type; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Helpers/Path.php: -------------------------------------------------------------------------------- 1 | map(fn (string $path) => trim($path, '/')) 11 | ->implode('/'); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Helpers/ResolveFeedItems.php: -------------------------------------------------------------------------------- 1 | 2) { 20 | $newResolver = array_merge([$newResolver], array_slice($resolver, 2, true)); 21 | } 22 | } 23 | 24 | return self::callFeedItemsResolver($newResolver); 25 | } 26 | 27 | protected static function callFeedItemsResolver($resolver): Collection 28 | { 29 | $resolver = Arr::wrap($resolver); 30 | 31 | $items = app()->call( 32 | array_shift($resolver), 33 | $resolver 34 | ); 35 | 36 | return $items; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Http/FeedController.php: -------------------------------------------------------------------------------- 1 | currentRouteName(), 'feeds.'); 16 | 17 | $feed = $feeds[$name] ?? null; 18 | 19 | abort_unless($feed, 404); 20 | 21 | $items = ResolveFeedItems::resolve($name, $feed['items']); 22 | 23 | return new Feed( 24 | $feed['title'], 25 | $items, 26 | request()->url(), 27 | $feed['view'] ?? 'feed::atom', 28 | $feed['description'] ?? '', 29 | $feed['language'] ?? 'en-US', 30 | $feed['image'] ?? '', 31 | $feed['format'] ?? 'atom', 32 | $feed['xsl'] ?? '', 33 | ); 34 | } 35 | } 36 | --------------------------------------------------------------------------------