├── src ├── Contracts │ └── Transformer.php ├── Enums │ └── FeedFormatEnum.php ├── Data │ └── ElementData.php ├── Helpers │ ├── ClassExistsHelper.php │ ├── ConverterHelper.php │ └── ScheduleFeedHelper.php ├── Exceptions │ ├── FeedNotFoundException.php │ ├── UnexpectedClassException.php │ ├── InvalidExpressionException.php │ ├── ResourceMetaException.php │ ├── WriteFeedException.php │ ├── InvalidFeedArgumentException.php │ ├── OpenFeedException.php │ ├── CloseFeedException.php │ ├── UnknownFeedClassException.php │ └── FeedGenerationException.php ├── Feeds │ ├── Info │ │ └── FeedInfo.php │ ├── Items │ │ └── FeedItem.php │ └── Feed.php ├── Events │ ├── FeedStartingEvent.php │ └── FeedFinishedEvent.php ├── Transformers │ ├── NullTransformer.php │ ├── BoolTransformer.php │ ├── EnumTransformer.php │ ├── SpecialCharsTransformer.php │ └── DateTimeTransformer.php ├── Publishers │ ├── MigrationPublisher.php │ ├── OperationPublisher.php │ └── Publisher.php ├── Converters │ ├── RssConverter.php │ ├── Converter.php │ ├── CsvConverter.php │ ├── JsonLinesConverter.php │ ├── JsonConverter.php │ └── XmlConverter.php ├── Casts │ ├── ExpressionCast.php │ └── ClassCast.php ├── Services │ ├── TransformerService.php │ ├── FilesystemService.php │ ├── GeneratorService.php │ └── ExportService.php ├── Commands │ ├── FeedInfoMakeCommand.php │ ├── FeedItemMakeCommand.php │ ├── FeedGenerateCommand.php │ └── FeedMakeCommand.php ├── Concerns │ └── InteractsWithName.php ├── Presets │ ├── InstagramFeedPreset.php │ ├── Items │ │ ├── SitemapFeedItem.php │ │ ├── RssFeedItem.php │ │ ├── YandexFeedItem.php │ │ └── InstagramFeedItem.php │ ├── RssFeedPreset.php │ ├── SitemapFeedPreset.php │ ├── YandexFeedPreset.php │ └── Info │ │ └── YandexFeedInfo.php ├── Models │ └── Feed.php ├── Queries │ └── FeedQuery.php └── LaravelFeedServiceProvider.php ├── stubs ├── feed_info.stub ├── transformer.stub ├── migration.stub ├── operation.stub ├── feed_item.stub ├── feed.stub └── converter.stub ├── LICENSE ├── database └── migrations │ └── 2025_09_01_231655_create_feeds_table.php ├── README.md ├── ide.json ├── composer.json └── config └── feeds.php /src/Contracts/Transformer.php: -------------------------------------------------------------------------------- 1 | $feed Reference to the feed class 15 | * @return void 16 | */ 17 | public function __construct( 18 | public string $feed, 19 | ) {} 20 | } 21 | -------------------------------------------------------------------------------- /src/Exceptions/OpenFeedException.php: -------------------------------------------------------------------------------- 1 | getMessage() . ": [$path]", previous: $e); 16 | } 17 | } 18 | // @codeCoverageIgnoreEnd 19 | -------------------------------------------------------------------------------- /src/Transformers/NullTransformer.php: -------------------------------------------------------------------------------- 1 | getMessage() . ": [$path]", previous: $e); 16 | } 17 | } 18 | // @codeCoverageIgnoreEnd 19 | -------------------------------------------------------------------------------- /src/Publishers/MigrationPublisher.php: -------------------------------------------------------------------------------- 1 | create( 15 | class : DummyBaseClass::class, 16 | title : 'DummyTitle', 17 | expression: '* * * * *' 18 | ); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/Exceptions/UnknownFeedClassException.php: -------------------------------------------------------------------------------- 1 | $feed Reference to the feed class 15 | * @param string $path Path to the generated feed file 16 | * @return void 17 | */ 18 | public function __construct( 19 | public string $feed, 20 | public string $path, 21 | ) {} 22 | } 23 | -------------------------------------------------------------------------------- /stubs/operation.stub: -------------------------------------------------------------------------------- 1 | create( 13 | class : DummyBaseClass::class, 14 | title : 'DummyTitle', 15 | expression: '* * * * *' 16 | ); 17 | } 18 | 19 | public function withinTransactions(): bool 20 | { 21 | return false; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/Transformers/EnumTransformer.php: -------------------------------------------------------------------------------- 1 | value ?? $value->name); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Converters/RssConverter.php: -------------------------------------------------------------------------------- 1 | createElement('item'); 15 | 16 | if ($values = $item->attributes()) { 17 | // @codeCoverageIgnoreStart 18 | $this->setAttributes($element, $values); 19 | // @codeCoverageIgnoreEnd 20 | } 21 | 22 | return $element; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Transformers/SpecialCharsTransformer.php: -------------------------------------------------------------------------------- 1 | removeControlCharacters( 19 | htmlspecialchars((string) $value) 20 | ); 21 | } 22 | 23 | protected function removeControlCharacters(string $value): string 24 | { 25 | return preg_replace('/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/', '', $value); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exceptions/FeedGenerationException.php: -------------------------------------------------------------------------------- 1 | */ 14 | public readonly string $feed; 15 | 16 | /** 17 | * @param class-string $feed 18 | */ 19 | public function __construct(string $feed, Throwable $e) 20 | { 21 | parent::__construct($e->getMessage(), previous: $e); 22 | 23 | $this->feed = $feed; 24 | } 25 | 26 | /** 27 | * @return class-string 28 | */ 29 | public function getFeed(): string 30 | { 31 | return $this->feed; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /stubs/feed_item.stub: -------------------------------------------------------------------------------- 1 | [ 16 | 'id' => $this->model->id, 17 | 18 | 'updated_at' => $this->model->updated_at->toDateTimeString(), 19 | 20 | 'verified' => ! empty($this->model->email_verified_at), 21 | ], 22 | 23 | 'name' => [ 24 | '@cdata' => '

' . $this->model->name . '

', 25 | ], 26 | 27 | 'email' => $this->model->email, 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Feeds/Items/FeedItem.php: -------------------------------------------------------------------------------- 1 | name ??= Str::kebab(class_basename($this->model)); 29 | } 30 | 31 | public function attributes(): array 32 | { 33 | return []; 34 | } 35 | 36 | public function toArray(): array 37 | { 38 | return $this->model->toArray(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /stubs/feed.stub: -------------------------------------------------------------------------------- 1 | isValid($value)) { 23 | throw new InvalidExpressionException($value); 24 | } 25 | 26 | return Str::of($value)->squish()->trim()->toString(); 27 | } 28 | 29 | protected function isValid(string $value): bool 30 | { 31 | return CronExpression::isValidExpression($value); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Services/TransformerService.php: -------------------------------------------------------------------------------- 1 | transformers($transformers) as $transformer) { 16 | if ($transformer->allow($value)) { 17 | $value = $transformer->transform($value); 18 | } 19 | } 20 | 21 | return $value; 22 | } 23 | 24 | /** 25 | * @return \DragonCode\LaravelFeed\Contracts\Transformer[] 26 | */ 27 | protected function transformers(array $transformers): array 28 | { 29 | return (new Collection(config('feeds.transformers'))) 30 | ->merge($transformers) 31 | ->map(static fn (string $transformer) => new $transformer) 32 | ->unique() 33 | ->all(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Commands/FeedInfoMakeCommand.php: -------------------------------------------------------------------------------- 1 | app(XmlConverter::class), 23 | FeedFormatEnum::Json => app(JsonConverter::class), 24 | FeedFormatEnum::JsonLines => app(JsonLinesConverter::class), 25 | FeedFormatEnum::Csv => app(CsvConverter::class), 26 | FeedFormatEnum::Rss => app(RssConverter::class), 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Concerns/InteractsWithName.php: -------------------------------------------------------------------------------- 1 | whenEndsWith('Feed', fn (Stringable $str) => $str->substr(0, -4)) 20 | ->whenEndsWith('FeedItem', fn (Stringable $str) => $str->substr(0, -8)) 21 | ->toString(); 22 | } 23 | 24 | protected function qualifyClass($name): string 25 | { 26 | return Str::finish(parent::qualifyClass($name), $this->type); 27 | } 28 | 29 | protected function buildClass($name): string 30 | { 31 | return str_replace( 32 | ['DummyUser'], 33 | class_basename($this->userProviderModel()), 34 | parent::buildClass($name) 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Andrey Helldar 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 | -------------------------------------------------------------------------------- /src/Converters/Converter.php: -------------------------------------------------------------------------------- 1 | transformer->transform($value, $this->transformers); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Transformers/DateTimeTransformer.php: -------------------------------------------------------------------------------- 1 | resolve($value) 26 | ->when($this->timezone(), fn (Carbon $date, string $zone) => $date->setTimezone($zone)) 27 | ->format($this->format()); 28 | } 29 | 30 | protected function resolve(mixed $date): Carbon 31 | { 32 | return Carbon::parse($date); 33 | } 34 | 35 | protected function format(): string 36 | { 37 | return config('feeds.date.format'); 38 | } 39 | 40 | protected function timezone(): string 41 | { 42 | return config('feeds.date.timezone'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Presets/InstagramFeedPreset.php: -------------------------------------------------------------------------------- 1 | 34 | 35 | $name 36 | $url 37 | 38 | XML; 39 | } 40 | 41 | public function footer(): string 42 | { 43 | return "\n\n"; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Presets/Items/SitemapFeedItem.php: -------------------------------------------------------------------------------- 1 | url = $url; 27 | 28 | return $this; 29 | } 30 | 31 | public function modifiedAt(Carbon $updatedAt): static 32 | { 33 | $this->modifiedAt = $updatedAt->toIso8601String(); 34 | 35 | return $this; 36 | } 37 | 38 | public function priority(float $priority): static 39 | { 40 | $this->priority = $priority; 41 | 42 | return $this; 43 | } 44 | 45 | public function toArray(): array 46 | { 47 | return [ 48 | 'loc' => $this->url, 49 | 'lastmod' => $this->modifiedAt, 50 | 'priority' => $this->priority, 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Models/Feed.php: -------------------------------------------------------------------------------- 1 | '* * * * *', 31 | 32 | 'is_active' => true, 33 | ]; 34 | 35 | public function getConnectionName(): ?string 36 | { 37 | return config('feeds.table.connection'); 38 | } 39 | 40 | public function getTable(): ?string 41 | { 42 | return config('feeds.table.table') ?? parent::getTable(); 43 | } 44 | 45 | protected function casts(): array 46 | { 47 | return [ 48 | 'class' => ClassCast::class, 49 | 'expression' => ExpressionCast::class, 50 | 51 | 'is_active' => 'boolean', 52 | 53 | 'last_activity' => 'datetime', 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Presets/RssFeedPreset.php: -------------------------------------------------------------------------------- 1 | guid($model->getKey()) 28 | ->publishedAt($model->created_at ?? Carbon::now()); 29 | } 30 | 31 | public function header(): string 32 | { 33 | return implode("\n", [ 34 | '', 35 | '', 36 | ]); 37 | } 38 | 39 | public function footer(): string 40 | { 41 | return ''; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /database/migrations/2025_09_01_231655_create_feeds_table.php: -------------------------------------------------------------------------------- 1 | schema()->create($this->table(), function (Blueprint $table) { 14 | $table->id(); 15 | 16 | $table->string('class')->unique(); 17 | $table->string('title'); 18 | 19 | $table->string('expression'); 20 | 21 | $table->boolean('is_active'); 22 | 23 | $table->timestamp('last_activity')->nullable(); 24 | $table->timestamps(); 25 | $table->softDeletes(); 26 | }); 27 | } 28 | 29 | public function down(): void 30 | { 31 | $this->schema()->dropIfExists($this->table()); 32 | } 33 | 34 | protected function schema(): Builder 35 | { 36 | return Schema::connection($this->connection()); 37 | } 38 | 39 | protected function connection(): ?string 40 | { 41 | return config('feeds.table.connection'); 42 | } 43 | 44 | protected function table(): string 45 | { 46 | return config('feeds.table.table'); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/Presets/SitemapFeedPreset.php: -------------------------------------------------------------------------------- 1 | 'http://www.sitemaps.org/schemas/sitemap/0.9', 19 | 'xmlns:xhtml' => 'http://www.w3.org/1999/xhtml', 20 | 'xmlns:image' => 'http://www.google.com/schemas/sitemap-image/1.1', 21 | 'xmlns:video' => 'http://www.google.com/schemas/sitemap-video/1.1', 22 | 'xmlns:news' => 'http://www.google.com/schemas/sitemap-news/0.9', 23 | ]; 24 | 25 | public function root(): ElementData 26 | { 27 | return new ElementData( 28 | name : $this->name, 29 | attributes: $this->attributes, 30 | ); 31 | } 32 | 33 | public function item(Model $model): FeedItem 34 | { 35 | return (new SitemapFeedItem($model)) 36 | ->modifiedAt($model->updated_at); 37 | } 38 | 39 | public function perFile(): int 40 | { 41 | return 50000; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Presets/YandexFeedPreset.php: -------------------------------------------------------------------------------- 1 | attributeId($model->getKey()); 32 | } 33 | 34 | public function header(): string 35 | { 36 | $date = Carbon::now()->toIso8601String(); 37 | 38 | return << 40 | 41 | 42 | XML; 43 | } 44 | 45 | public function footer(): string 46 | { 47 | return "\n"; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Helpers/ScheduleFeedHelper.php: -------------------------------------------------------------------------------- 1 | commands(); 29 | } 30 | 31 | public function commands(): void 32 | { 33 | $this->query->active()->each( 34 | fn (Feed $feed) => $this->schedule($feed) 35 | ); 36 | } 37 | 38 | protected function schedule(Feed $feed): void 39 | { 40 | $event = $this->event($feed); 41 | 42 | if ($this->canBackground) { 43 | $event->runInBackground(); 44 | } 45 | } 46 | 47 | protected function event(Feed $feed): Event 48 | { 49 | return Schedule::command(FeedGenerateCommand::class, [$feed->id]) 50 | ->withoutOverlapping($this->ttl) 51 | ->cron($feed->expression); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /stubs/converter.stub: -------------------------------------------------------------------------------- 1 | performItem($item->toArray()); 34 | 35 | return $this->encode($data); 36 | } 37 | 38 | public function info(array $info, bool $afterRoot): string 39 | { 40 | $data = $this->performItem($info); 41 | 42 | return $this->encode($data); 43 | } 44 | 45 | protected function performItem(array $data): array 46 | { 47 | foreach ($data as &$value) { 48 | if (is_array($value)) { 49 | $value = $this->performItem($value); 50 | 51 | continue; 52 | } 53 | 54 | $value = $this->transformValue($value); 55 | } 56 | 57 | return $data; 58 | } 59 | 60 | protected function encode(array $data): string 61 | { 62 | return implode(';', $data); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Queries/FeedQuery.php: -------------------------------------------------------------------------------- 1 | $class, 23 | 'title' => $title, 24 | 'expression' => $expression, 25 | 'is_active' => $isActive, 26 | ]); 27 | } 28 | 29 | public function find(int $id): Feed 30 | { 31 | return Feed::findOr($id, callback: static fn () => throw new FeedNotFoundException($id)); 32 | } 33 | 34 | public function all(): Builder 35 | { 36 | return Feed::query()->orderBy('id'); 37 | } 38 | 39 | public function active(): Builder 40 | { 41 | return Feed::query() 42 | ->where('is_active', true) 43 | ->orderBy('id'); 44 | } 45 | 46 | public function setLastActivity(string $class): void 47 | { 48 | Feed::query() 49 | ->whereClass($class) 50 | ->update(['last_activity' => now()]); 51 | } 52 | 53 | public function delete(int $id): void 54 | { 55 | Feed::destroy($id); 56 | } 57 | 58 | public function restore(int $id): void 59 | { 60 | Feed::query() 61 | ->whereId($id) 62 | ->restore(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/LaravelFeedServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__ . '/../config/feeds.php', 'feeds'); 18 | } 19 | 20 | public function boot(): void 21 | { 22 | // @codeCoverageIgnoreStart 23 | if (! $this->app->runningInConsole()) { 24 | return; 25 | } 26 | // @codeCoverageIgnoreEnd 27 | 28 | $this->registerCommands(); 29 | $this->publishConfig(); 30 | $this->migrations(); 31 | } 32 | 33 | protected function publishConfig(): void 34 | { 35 | $this->publishes([ 36 | __DIR__ . '/../config/feeds.php' => $this->app->configPath('feeds.php'), 37 | ], ['config', 'feeds']); 38 | } 39 | 40 | protected function migrations(): void 41 | { 42 | $this->publishesMigrations([ 43 | __DIR__ . '/../database/migrations' => $this->app->databasePath('migrations'), 44 | ], 'feeds'); 45 | } 46 | 47 | protected function registerCommands(): void 48 | { 49 | $this->commands([ 50 | FeedGenerateCommand::class, 51 | FeedInfoMakeCommand::class, 52 | FeedItemMakeCommand::class, 53 | FeedMakeCommand::class, 54 | ]); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Converters/CsvConverter.php: -------------------------------------------------------------------------------- 1 | performItem($item->toArray()); 43 | 44 | return $this->encode($data); 45 | } 46 | 47 | public function info(array $info, bool $afterRoot): string 48 | { 49 | $data = $this->performItem($info); 50 | 51 | return $this->encode($data); 52 | } 53 | 54 | protected function performItem(array $data): array 55 | { 56 | foreach ($data as &$value) { 57 | if (! is_array($value)) { 58 | $value = $this->transformValue($value); 59 | } 60 | } 61 | 62 | return $data; 63 | } 64 | 65 | protected function encode(array $data): string 66 | { 67 | return implode($this->delimiter, $data); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Converters/JsonLinesConverter.php: -------------------------------------------------------------------------------- 1 | options &= ~JSON_PRETTY_PRINT; 25 | } 26 | 27 | public function header(Feed $feed): string 28 | { 29 | return ''; 30 | } 31 | 32 | public function footer(Feed $feed): string 33 | { 34 | return ''; 35 | } 36 | 37 | public function root(Feed $feed): string 38 | { 39 | return ''; 40 | } 41 | 42 | public function item(FeedItem $item, bool $isLast): string 43 | { 44 | $data = $this->performItem($item->toArray()); 45 | 46 | return $this->encode($data); 47 | } 48 | 49 | public function info(array $info, bool $afterRoot): string 50 | { 51 | $data = $this->performItem($info); 52 | 53 | return $this->encode($data); 54 | } 55 | 56 | protected function performItem(array $data): array 57 | { 58 | foreach ($data as &$value) { 59 | if (is_array($value)) { 60 | $value = $this->performItem($value); 61 | 62 | continue; 63 | } 64 | 65 | $value = $this->transformValue($value); 66 | } 67 | 68 | return $data; 69 | } 70 | 71 | protected function encode(array $data): string 72 | { 73 | return json_encode($data, $this->options); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Presets/Items/RssFeedItem.php: -------------------------------------------------------------------------------- 1 | guid = (string) $guid; 33 | 34 | return $this; 35 | } 36 | 37 | public function title(string $title): static 38 | { 39 | $this->title = $title; 40 | 41 | return $this; 42 | } 43 | 44 | public function url(string $url): static 45 | { 46 | $this->url = $url; 47 | 48 | return $this; 49 | } 50 | 51 | public function description(string $description): static 52 | { 53 | $this->description = $description; 54 | 55 | return $this; 56 | } 57 | 58 | public function category(string $category): static 59 | { 60 | $this->category = $category; 61 | 62 | return $this; 63 | } 64 | 65 | public function publishedAt(Carbon $publishedAt): static 66 | { 67 | $this->publishedAt = $publishedAt; 68 | 69 | return $this; 70 | } 71 | 72 | public function additional(array $additional): static 73 | { 74 | $this->additional = $additional; 75 | 76 | return $this; 77 | } 78 | 79 | public function toArray(): array 80 | { 81 | return collect([ 82 | 'title' => $this->title, 83 | 84 | 'link' => $this->url, 85 | 'guid' => $this->guid, 86 | 87 | 'description' => ['@cdata' => $this->description], 88 | 89 | 'category' => $this->category, 90 | 91 | 'pubDate' => $this->publishedAt->toRfc1123String(), 92 | ]) 93 | ->merge($this->additional) 94 | ->reject(static fn (mixed $value) => blank($value)) 95 | ->all(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Converters/JsonConverter.php: -------------------------------------------------------------------------------- 1 | root()->name ? "{\n" : "[\n"; 32 | } 33 | 34 | public function footer(Feed $feed): string 35 | { 36 | return $feed->root()->name ? "\n]\n}\n" : "\n]\n"; 37 | } 38 | 39 | public function root(Feed $feed): string 40 | { 41 | return sprintf("\"%s\": [\n", $feed->root()->name); 42 | } 43 | 44 | public function item(FeedItem $item, bool $isLast): string 45 | { 46 | $data = $this->performItem($item->toArray()); 47 | 48 | $suffix = $isLast ? '' : ','; 49 | 50 | return $this->encode($data) . $suffix; 51 | } 52 | 53 | public function info(array $info, bool $afterRoot): string 54 | { 55 | $data = $this->performItem($info); 56 | 57 | $json = $this->encode($data); 58 | 59 | if (! $afterRoot) { 60 | $json = mb_substr($json, 1, -1); 61 | } 62 | 63 | return $json . ','; 64 | } 65 | 66 | protected function performItem(array $data): array 67 | { 68 | foreach ($data as &$value) { 69 | if (is_array($value)) { 70 | $value = $this->performItem($value); 71 | 72 | continue; 73 | } 74 | 75 | $value = $this->transformValue($value); 76 | } 77 | 78 | return $data; 79 | } 80 | 81 | protected function encode(array $data): string 82 | { 83 | return json_encode($data, $this->jsonOptions()); 84 | } 85 | 86 | protected function jsonOptions(): int 87 | { 88 | if ($this->pretty) { 89 | return JSON_PRETTY_PRINT | $this->options; 90 | } 91 | 92 | return $this->options; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📃 Laravel Feeds 2 | 3 | ![the dragon code laravel feeds](docs/images/social-logo.png#gh-light-mode-only) 4 | ![the dragon code laravel feeds](docs/images/social-logo_dark.png#gh-dark-mode-only) 5 | 6 | [![Stable Version][badge_stable]][link_packagist] 7 | [![Total Downloads][badge_downloads]][link_packagist] 8 | [![License][badge_license]][link_license] 9 | 10 | **Laravel Feeds** is an easy and fast way to export large amounts of data into feeds for marketplaces and other 11 | consumers. 12 | 13 | > **🌟 Features** 14 | > 15 | > - Chunked queries to the database 16 | > - Draft mode during processing 17 | > - Easy property mapping 18 | > - Generate feeds, sitemaps, and more 19 | 20 | ## Installation 21 | 22 | You can install the **Laravel Feeds** package via [Composer](https://getcomposer.org): 23 | 24 | ```Bash 25 | composer require dragon-code/laravel-feeds 26 | ``` 27 | 28 | You should publish the [migration](database/migrations/2025_09_01_231655_create_feeds_table.php) and 29 | the [config/feeds.php](config/feeds.php) file with: 30 | 31 | ```bash 32 | php artisan vendor:publish --tag="feeds" 33 | ``` 34 | 35 | > [!WARNING] 36 | > 37 | > Before running migrations, verify the database connection settings in [config/feeds.php](config/feeds.php). 38 | 39 | Now you can run migrations and proceed to [create feeds](https://feeds.dragon-code.pro/create-feeds.html). 40 | 41 | ## Basic Usage 42 | 43 | To create a feed class, use the `make:feed` console command: 44 | 45 | ```bash 46 | php artisan make:feed User -t 47 | ``` 48 | 49 | As a result of executing the console command, the files `app/Feeds/UserFeed.php` and `app/Feeds/Items/UserFeedItem.php` 50 | will be created. 51 | 52 | Check the [operation/migration](https://feeds.dragon-code.pro/create-feeds.html) file that was created for you and run 53 | the console command: 54 | 55 | ```bash 56 | # For Laravel Deploy Operations 57 | php artisan operations 58 | 59 | # For Laravel Migrations 60 | php artisan migrate 61 | ``` 62 | 63 | To generate all active feeds, use the console command: 64 | 65 | ```bash 66 | php artisan feed:generate 67 | ``` 68 | 69 | ## Documentation 70 | 71 | 📚 You will find full documentation on the dedicated [documentation](https://feeds.dragon-code.pro) site. 72 | 73 | ## License 74 | 75 | This package is licensed under the [MIT License](LICENSE). 76 | 77 | 78 | [badge_downloads]: https://img.shields.io/packagist/dt/dragon-code/laravel-feeds.svg?style=flat-square 79 | 80 | [badge_license]: https://img.shields.io/packagist/l/dragon-code/laravel-feeds.svg?style=flat-square 81 | 82 | [badge_stable]: https://img.shields.io/github/v/release/TheDragonCode/laravel-feeds?label=packagist&style=flat-square 83 | 84 | [link_license]: LICENSE 85 | 86 | [link_packagist]: https://packagist.org/packages/dragon-code/laravel-feeds 87 | -------------------------------------------------------------------------------- /src/Publishers/Publisher.php: -------------------------------------------------------------------------------- 1 | classBasename() 33 | ->before('Publisher') 34 | ->toString(); 35 | } 36 | 37 | public function publish(): string 38 | { 39 | return $this->store( 40 | $this->path(), 41 | $this->replace() 42 | ); 43 | } 44 | 45 | protected function store(string $path, string $contents): string 46 | { 47 | $this->filesystem->ensureDirectoryExists(dirname($path)); 48 | $this->filesystem->put($path, $contents); 49 | 50 | return $path; 51 | } 52 | 53 | protected function replace(): string 54 | { 55 | return str_replace( 56 | ['DummyClass', 'DummyBaseClass', 'DummyTitle'], 57 | [$this->class, $this->baseClass(), $this->title()], 58 | $this->load() 59 | ); 60 | } 61 | 62 | protected function baseClass(): string 63 | { 64 | return class_basename($this->class); 65 | } 66 | 67 | protected function title(): string 68 | { 69 | return Str::of($this->title) 70 | ->replace(['\\', '/'], ': ') 71 | ->snake(' ') 72 | ->title() 73 | ->toString(); 74 | } 75 | 76 | protected function path(): string 77 | { 78 | return vsprintf('%s/%s_%s.php', [ 79 | $this->basePath(), 80 | $this->date(), 81 | $this->filename(), 82 | ]); 83 | } 84 | 85 | protected function filename(): string 86 | { 87 | return Str::of($this->title) 88 | ->snake() 89 | ->prepend('create_') 90 | ->append('_feed') 91 | ->toString(); 92 | } 93 | 94 | protected function date(): string 95 | { 96 | return Carbon::now()->format('Y_m_d_His'); 97 | } 98 | 99 | protected function load(): string 100 | { 101 | return file_get_contents($this->template()); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Commands/FeedGenerateCommand.php: -------------------------------------------------------------------------------- 1 | feedable($query) as $feed => $enabled) { 27 | if (! $enabled) { 28 | $this->components->twoColumnDetail($feed, $this->messageYellow('SKIP')); 29 | 30 | continue; 31 | } 32 | 33 | $this->hasProgressBar() 34 | ? $this->performWithProgressBar($generator, $feed) 35 | : $this->performWithoutProgressBar($generator, $feed); 36 | } 37 | } 38 | 39 | protected function performWithProgressBar(GeneratorService $generator, string $feed): void 40 | { 41 | $this->components->info($feed); 42 | 43 | $generator->feed(app($feed), $this->output); 44 | } 45 | 46 | protected function performWithoutProgressBar(GeneratorService $generator, string $feed): void 47 | { 48 | $this->components->task($feed, fn () => $generator->feed(app($feed))); 49 | } 50 | 51 | protected function feedable(FeedQuery $feeds): array 52 | { 53 | if (! $id = $this->argument('feed')) { 54 | return $feeds->all() 55 | ->pluck('is_active', 'class') 56 | ->all(); 57 | } 58 | 59 | if (! is_numeric($id)) { 60 | throw new InvalidFeedArgumentException($id); 61 | } 62 | 63 | $feed = $feeds->find((int) $id); 64 | 65 | return [$feed->class => true]; 66 | } 67 | 68 | protected function messageYellow(string $message): string 69 | { 70 | if ($this->option('no-ansi')) { 71 | // @codeCoverageIgnoreStart 72 | return $message; 73 | // @codeCoverageIgnoreEnd 74 | } 75 | 76 | return $this->yellow($message); 77 | } 78 | 79 | protected function hasProgressBar(): bool 80 | { 81 | return config()?->boolean('feeds.console.progress_bar'); 82 | } 83 | 84 | protected function getArguments(): array 85 | { 86 | return [ 87 | ['feed', InputArgument::OPTIONAL, 'The Feed ID for generation (from the database)'], 88 | ]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /ide.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://laravel-ide.com/schema/laravel-ide-v2.json", 3 | "codeGenerations": [ 4 | { 5 | "id": "dragon-code.feeds.feed", 6 | "name": "Create Feed", 7 | "classSuffix": "Feed", 8 | "regex": ".+", 9 | "files": [ 10 | { 11 | "appNamespace": "Feeds", 12 | "name": "${INPUT_CLASS|replace: ,_|className|upperCamelCase}.php", 13 | "template": { 14 | "type": "stub", 15 | "path": "/stubs/feed.stub", 16 | "fallbackPath": "stubs/feed.stub", 17 | "parameters": { 18 | "DummyNamespace": "${INPUT_FQN|namespace}", 19 | "DummyClass": "${INPUT_CLASS|replace: ,_|className|upperCamelCase}", 20 | "NamespacedDummyUserModel": "App\\Models\\User", 21 | "DummyUser": "User" 22 | } 23 | } 24 | } 25 | ] 26 | }, 27 | { 28 | "id": "dragon-code.feeds.feed-item", 29 | "name": "Create Feed Item", 30 | "classSuffix": "FeedItem", 31 | "regex": ".+", 32 | "files": [ 33 | { 34 | "appNamespace": "Feeds\\Items", 35 | "name": "${INPUT_CLASS|replace: ,_|className|upperCamelCase}.php", 36 | "template": { 37 | "type": "stub", 38 | "path": "/stubs/feed_item.stub", 39 | "fallbackPath": "stubs/feed_item.stub", 40 | "parameters": { 41 | "DummyNamespace": "${INPUT_FQN|namespace}", 42 | "DummyClass": "${INPUT_CLASS|replace: ,_|className|upperCamelCase}", 43 | "NamespacedDummyUserModel": "App\\Models\\User" 44 | } 45 | } 46 | } 47 | ] 48 | }, 49 | { 50 | "id": "dragon-code.feeds.feed-info", 51 | "name": "Create Feed Info", 52 | "classSuffix": "FeedInfo", 53 | "regex": ".+", 54 | "files": [ 55 | { 56 | "appNamespace": "Feeds\\Info", 57 | "name": "${INPUT_CLASS|replace: ,_|className|upperCamelCase}.php", 58 | "template": { 59 | "type": "stub", 60 | "path": "/stubs/feed_info.stub", 61 | "fallbackPath": "stubs/feed_info.stub", 62 | "parameters": { 63 | "DummyNamespace": "${INPUT_FQN|namespace}", 64 | "DummyClass": "${INPUT_CLASS|replace: ,_|className|upperCamelCase}" 65 | } 66 | } 67 | } 68 | ] 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /src/Commands/FeedMakeCommand.php: -------------------------------------------------------------------------------- 1 | option('item')) { 31 | $this->makeFeedItem( 32 | $this->argument('name'), 33 | (bool) $this->option('force') 34 | ); 35 | } 36 | 37 | if ($this->option('info')) { 38 | $this->makeFeedInfo( 39 | $this->argument('name'), 40 | (bool) $this->option('force') 41 | ); 42 | } 43 | 44 | $this->makeOperation( 45 | $this->argument('name'), 46 | $this->getQualifyClass() 47 | ); 48 | } 49 | 50 | protected function makeOperation(string $name, string $class): void 51 | { 52 | $publisher = $this->hasOperations() 53 | ? app(OperationPublisher::class, ['title' => $name, 'class' => $class]) 54 | : app(MigrationPublisher::class, ['title' => $name, 'class' => $class]); 55 | 56 | $this->components->info(vsprintf('%s [%s] created successfully.', [ 57 | $publisher->name(), 58 | $publisher->publish(), 59 | ])); 60 | } 61 | 62 | protected function makeFeedItem(string $name, bool $force): void 63 | { 64 | $this->call(FeedItemMakeCommand::class, [ 65 | 'name' => $name, 66 | '--force' => $force, 67 | ]); 68 | } 69 | 70 | protected function makeFeedInfo(string $name, bool $force): void 71 | { 72 | $this->call(FeedInfoMakeCommand::class, [ 73 | 'name' => $name, 74 | '--force' => $force, 75 | ]); 76 | } 77 | 78 | protected function getStub(): string 79 | { 80 | return __DIR__ . '/../../stubs/feed.stub'; 81 | } 82 | 83 | protected function getDefaultNamespace($rootNamespace): string // @pest-ignore-type 84 | { 85 | return $rootNamespace . '\Feeds'; 86 | } 87 | 88 | protected function getQualifyClass(): string 89 | { 90 | return $this->qualifyClass($this->getNameInput()); 91 | } 92 | 93 | protected function hasOperations(): bool 94 | { 95 | return app(ClassExistsHelper::class)->exists(Operation::class); 96 | } 97 | 98 | protected function getOptions(): array 99 | { 100 | return [ 101 | ['item', 't', InputOption::VALUE_NONE, 'Create the class with feed item'], 102 | ['info', 'i', InputOption::VALUE_NONE, 'Create the class with feed info'], 103 | ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the feed already exists'], 104 | ]; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dragon-code/laravel-feeds", 3 | "description": "Fast export of large datasets to feeds for marketplaces and services", 4 | "license": "MIT", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Andrey Helldar", 9 | "email": "helldar@dragon-code.pro", 10 | "homepage": "https://dragon-code.pro" 11 | } 12 | ], 13 | "require": { 14 | "php": "^8.2", 15 | "ext-dom": "*", 16 | "ext-libxml": "*", 17 | "dragonmantank/cron-expression": "^3.4", 18 | "illuminate/database": "^11.0 || ^12.0", 19 | "illuminate/filesystem": "^11.0 || ^12.0", 20 | "illuminate/support": "^11.0 || ^12.0", 21 | "laravel/prompts": ">=0.3.6", 22 | "spatie/temporary-directory": "^2.3" 23 | }, 24 | "require-dev": { 25 | "dragon-code/codestyler": "^6.3", 26 | "dragon-code/laravel-deploy-operations": "^7.1", 27 | "laravel/boost": "^1.1", 28 | "mockery/mockery": "^1.6", 29 | "orchestra/testbench": "^9.0 || ^10.0", 30 | "pestphp/pest": "^3.0 || ^4.0", 31 | "pestphp/pest-plugin-laravel": "^3.0 || ^4.0", 32 | "pestphp/pest-plugin-type-coverage": "^3.0 || ^4.0", 33 | "symfony/var-dumper": "^7.3" 34 | }, 35 | "minimum-stability": "stable", 36 | "autoload": { 37 | "psr-4": { 38 | "DragonCode\\LaravelFeed\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Tests\\": "tests/", 44 | "Workbench\\App\\": "workbench/app/", 45 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 46 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 47 | } 48 | }, 49 | "config": { 50 | "allow-plugins": { 51 | "ergebnis/composer-normalize": true, 52 | "laravel/pint": true, 53 | "pestphp/pest-plugin": true 54 | } 55 | }, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "DragonCode\\LaravelFeed\\LaravelFeedServiceProvider" 60 | ] 61 | } 62 | }, 63 | "scripts": { 64 | "post-update-cmd": [ 65 | "@php vendor/bin/codestyle pint 8.2 --ansi", 66 | "@php vendor/bin/codestyle editorconfig --ansi", 67 | "composer normalize --ansi", 68 | "@ai" 69 | ], 70 | "post-autoload-dump": [ 71 | "@clear", 72 | "@prepare" 73 | ], 74 | "ai": "@php vendor/bin/testbench boost:guidelines -n", 75 | "build": "@php vendor/bin/testbench workbench:build --ansi", 76 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 77 | "migrate": "@php vendor/bin/testbench migrate:fresh --seed --ansi", 78 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 79 | "reset:snippets": "git restore --source=HEAD -- ./docs/snippets/*.xml", 80 | "style": "@php vendor/bin/pint --parallel --ansi", 81 | "style:snippets": "@php vendor/bin/pint --dirty --ansi docs/snippets", 82 | "test": [ 83 | "@php vendor/bin/pest --parallel --colors=always", 84 | "@style:snippets", 85 | "@reset:snippets" 86 | ], 87 | "test:coverage": "@php vendor/bin/pest --colors=always --coverage --compact --parallel --min=95", 88 | "test:type": "@php vendor/bin/pest --type-coverage --compact --min=95", 89 | "test:update": [ 90 | "@php vendor/bin/pest --colors=always --update-snapshots", 91 | "@style:snippets" 92 | ] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Feeds/Feed.php: -------------------------------------------------------------------------------- 1 | format()) { 62 | FeedFormatEnum::Xml, 63 | FeedFormatEnum::Rss => '', 64 | default => '' 65 | }; 66 | } 67 | 68 | public function footer(): string 69 | { 70 | return ''; 71 | } 72 | 73 | public function root(): ElementData 74 | { 75 | return new ElementData( 76 | name: Str::of(static::class) 77 | ->classBasename() 78 | ->beforeLast(class_basename(self::class)) 79 | ->snake() 80 | ->toString() 81 | ); 82 | } 83 | 84 | public function info(): FeedInfo 85 | { 86 | return new FeedInfo; 87 | } 88 | 89 | public function filename(): string 90 | { 91 | return $this->filename ??= Str::of(static::class) 92 | ->after($this->laravel->getNamespace() . 'Feeds\\') 93 | ->ltrim('\\') 94 | ->replace('\\', ' ') 95 | ->kebab() 96 | ->append('.', $this->format()->value) 97 | ->toString(); 98 | } 99 | 100 | public function path(int|string $suffix = ''): string 101 | { 102 | if (empty($suffix)) { 103 | return $this->storage()->path( 104 | $this->filename() 105 | ); 106 | } 107 | 108 | $filename = $this->filename(); 109 | 110 | $directory = pathinfo($filename, PATHINFO_DIRNAME); 111 | $basename = pathinfo($filename, PATHINFO_FILENAME); 112 | $extension = pathinfo($filename, PATHINFO_EXTENSION); 113 | 114 | if ($suffix) { 115 | $suffix = '-' . $suffix; 116 | } 117 | 118 | return $this->storage()->path( 119 | "$directory/$basename$suffix.$extension" 120 | ); 121 | } 122 | 123 | public function storage(): Filesystem 124 | { 125 | return Storage::disk($this->storage); 126 | } 127 | 128 | public function format(): FeedFormatEnum 129 | { 130 | return $this->format; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Presets/Items/YandexFeedItem.php: -------------------------------------------------------------------------------- 1 | attributeId = $id; 47 | 48 | return $this; 49 | } 50 | 51 | public function attributeAvailable(bool $available): static 52 | { 53 | $this->attributeAvailable = $available; 54 | 55 | return $this; 56 | } 57 | 58 | public function attributeType(string $type): static 59 | { 60 | $this->attributeType = $type; 61 | 62 | return $this; 63 | } 64 | 65 | public function url(string $url): static 66 | { 67 | $this->url = $url; 68 | 69 | return $this; 70 | } 71 | 72 | public function barcode(string $barcode): static 73 | { 74 | $this->barcode = $barcode; 75 | 76 | return $this; 77 | } 78 | 79 | public function title(string $title): static 80 | { 81 | $this->title = $title; 82 | 83 | return $this; 84 | } 85 | 86 | public function description(string $description): static 87 | { 88 | $this->description = $description; 89 | 90 | return $this; 91 | } 92 | 93 | public function price(float|string $price): static 94 | { 95 | $this->price = (string) $price; 96 | 97 | return $this; 98 | } 99 | 100 | public function currencyId(string $currencyId): static 101 | { 102 | $this->currencyId = $currencyId; 103 | 104 | return $this; 105 | } 106 | 107 | public function vendor(?string $vendor): static 108 | { 109 | $this->vendor = $vendor; 110 | 111 | return $this; 112 | } 113 | 114 | public function images(array $images): static 115 | { 116 | $this->images = $images; 117 | 118 | return $this; 119 | } 120 | 121 | public function additional(array $additional): static 122 | { 123 | $this->additional = $additional; 124 | 125 | return $this; 126 | } 127 | 128 | public function attributes(): array 129 | { 130 | return [ 131 | 'id' => $this->attributeId, 132 | 'available' => $this->attributeAvailable, 133 | 'type' => $this->attributeType, 134 | ]; 135 | } 136 | 137 | public function toArray(): array 138 | { 139 | return collect([ 140 | 'url' => $this->url, 141 | 142 | 'barcode' => $this->barcode, 143 | 'name' => $this->title, 144 | 'description' => $this->description, 145 | 146 | 'price' => $this->price, 147 | 148 | 'currencyId' => $this->currencyId, 149 | 'vendor' => $this->vendor, 150 | 151 | '@picture' => $this->images, 152 | ]) 153 | ->merge($this->additional) 154 | ->reject(static fn (mixed $value) => blank($value)) 155 | ->all(); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Presets/Info/YandexFeedInfo.php: -------------------------------------------------------------------------------- 1 | [ 28 | 'id' => 'RUR', 29 | 'rate' => '1', 30 | ], 31 | ], 32 | ]; 33 | 34 | public array $categories = []; 35 | 36 | public array $additional = []; 37 | 38 | public function name(string $name): static 39 | { 40 | $this->name = $name; 41 | 42 | return $this; 43 | } 44 | 45 | public function company(string $name): static 46 | { 47 | $this->company = $name; 48 | 49 | return $this; 50 | } 51 | 52 | public function platform(string $name): static 53 | { 54 | $this->platform = $name; 55 | 56 | return $this; 57 | } 58 | 59 | public function url(string $url): static 60 | { 61 | $this->url = $url; 62 | 63 | return $this; 64 | } 65 | 66 | public function email(string $email): static 67 | { 68 | $this->email = $email; 69 | 70 | return $this; 71 | } 72 | 73 | public function currency(string $name, float $rate, bool $replace = false): static 74 | { 75 | if ($replace) { 76 | // @codeCoverageIgnoreStart 77 | $this->currencies = []; 78 | // @codeCoverageIgnoreEnd 79 | } 80 | 81 | $this->currencies[] = [ 82 | '@attributes' => [ 83 | 'id' => Str::upper($name), 84 | 'rate' => $rate, 85 | ], 86 | ]; 87 | 88 | return $this; 89 | } 90 | 91 | public function category(int|string $id, string $name, bool $replace = false): static 92 | { 93 | if ($replace) { 94 | // @codeCoverageIgnoreStart 95 | $this->categories = []; 96 | // @codeCoverageIgnoreEnd 97 | } 98 | 99 | $this->categories[] = [ 100 | '@attributes' => ['id' => $id], 101 | '@value' => $name, 102 | ]; 103 | 104 | return $this; 105 | } 106 | 107 | public function currencies(array $currencies): static 108 | { 109 | $this->currencies = []; 110 | 111 | foreach ($currencies as $name => $rate) { 112 | $this->currency($name, $rate); 113 | } 114 | 115 | return $this; 116 | } 117 | 118 | public function categories(array $categories): static 119 | { 120 | $this->categories = []; 121 | 122 | foreach ($categories as $id => $name) { 123 | $this->category($id, $name); 124 | } 125 | 126 | return $this; 127 | } 128 | 129 | public function additional(array $data): static 130 | { 131 | $this->additional = $data; 132 | 133 | return $this; 134 | } 135 | 136 | public function toArray(): array 137 | { 138 | return collect([ 139 | 'name' => $this->name ?? config('app.name'), 140 | 'company' => $this->company ?? config('app.name'), 141 | 'platform' => $this->platform ?? config('app.name'), 142 | 143 | 'url' => $this->url ?? config('app.url'), 144 | 'email' => $this->email, 145 | 146 | 'currencies' => ['@currency' => $this->currencies], 147 | 'categories' => ['@category' => $this->categories], 148 | ]) 149 | ->merge($this->additional) 150 | ->reject(static fn (mixed $value) => blank($value)) 151 | ->all(); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Services/FilesystemService.php: -------------------------------------------------------------------------------- 1 | draftPath($filename); 37 | 38 | try { 39 | $resource = fopen($temp, 'ab'); 40 | 41 | if ($resource === false) { 42 | // @codeCoverageIgnoreStart 43 | throw new RuntimeException('Unable to open resource for writing.'); 44 | // @codeCoverageIgnoreEnd 45 | } 46 | 47 | return $resource; 48 | // @codeCoverageIgnoreStart 49 | } catch (Throwable $e) { 50 | throw new OpenFeedException($temp, $e); 51 | // @codeCoverageIgnoreEnd 52 | } 53 | } 54 | 55 | /** 56 | * @param resource $resource 57 | */ 58 | public function append($resource, string $content, string $path): void // @pest-ignore-type 59 | { 60 | if (fwrite($resource, $content) === false) { 61 | // @codeCoverageIgnoreStart 62 | throw new WriteFeedException($path); 63 | // @codeCoverageIgnoreEnd 64 | } 65 | } 66 | 67 | /** 68 | * @param resource $resource 69 | */ 70 | public function release($resource, string $path): void // @pest-ignore-type 71 | { 72 | try { 73 | $temp = $this->getMetaPath($resource); 74 | 75 | $this->close($resource); 76 | 77 | if ($this->file->exists($path)) { 78 | $this->file->delete($path); 79 | } 80 | 81 | $this->file->ensureDirectoryExists( 82 | dirname($path) 83 | ); 84 | 85 | $this->file->move($temp, $path); 86 | 87 | $this->cleanTemporaryDirectory($temp); 88 | // @codeCoverageIgnoreStart 89 | } catch (Throwable $e) { 90 | throw new CloseFeedException($path, $e); 91 | } 92 | // @codeCoverageIgnoreEnd 93 | } 94 | 95 | /** 96 | * @param resource $resource 97 | */ 98 | public function close($resource): void // @pest-ignore-type 99 | { 100 | if (! is_resource($resource)) { 101 | // @codeCoverageIgnoreStart 102 | return; 103 | // @codeCoverageIgnoreEnd 104 | } 105 | 106 | fclose($resource); 107 | } 108 | 109 | protected function cleanTemporaryDirectory(string $filename): void 110 | { 111 | $this->file->deleteDirectory( 112 | dirname($filename) 113 | ); 114 | } 115 | 116 | protected function draftPath(string $filename): string 117 | { 118 | return (new TemporaryDirectory) 119 | ->name($this->temporaryFilename($filename)) 120 | ->create() 121 | ->path((string) microtime(true)); 122 | } 123 | 124 | protected function temporaryFilename(string $filename): string 125 | { 126 | return Str::of($filename) 127 | ->prepend('feeds_draft_') 128 | ->append('_', microtime(true)) 129 | ->slug('_') 130 | ->toString(); 131 | } 132 | 133 | /** 134 | * @param resource $file 135 | */ 136 | protected function getMetaPath($file): string // @pest-ignore-type 137 | { 138 | $meta = stream_get_meta_data($file); 139 | 140 | return $meta['uri'] ?? throw new ResourceMetaException; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /config/feeds.php: -------------------------------------------------------------------------------- 1 | (bool) env('FEED_PRETTY', false), 22 | 23 | /** 24 | * Output date/time options. 25 | */ 26 | 'date' => [ 27 | /** 28 | * Date/time format used when serializing timestamps to feeds. 29 | * Accepts any valid PHP date/time format string or constant. 30 | */ 31 | 'format' => DATE_ATOM, 32 | 33 | /** 34 | * The timezone applied when formatting dates. 35 | */ 36 | 'timezone' => env('FEED_TIMEZONE', 'UTC'), 37 | ], 38 | 39 | /** 40 | * Database table settings used by the package (for logs or internal state). 41 | */ 42 | 'table' => [ 43 | /** 44 | * The database connection name to use. 45 | * Should match a connection defined in config/database.php. 46 | */ 47 | 'connection' => env('DB_CONNECTION', 'sqlite'), 48 | 49 | /** 50 | * The database table name used by the package. 51 | */ 52 | 'table' => env('FEED_TABLE', 'feeds'), 53 | ], 54 | 55 | /** 56 | * Scheduling options for feed generation/update tasks. 57 | */ 58 | 'schedule' => [ 59 | /** 60 | * Time-to-live (in minutes) for the schedule lock or cache. 61 | * Helps prevent overlapping or excessively frequent runs. 62 | */ 63 | 'ttl' => (int) env('FEED_SCHEDULE_TTL', 1440), 64 | 65 | /** 66 | * Run scheduled jobs in the background. 67 | * When true, tasks are dispatched asynchronously to avoid blocking. 68 | */ 69 | 'background' => (bool) env('FEED_SCHEDULE_RUN_BACKGROUND', true), 70 | ], 71 | 72 | /** 73 | * Console display options. 74 | */ 75 | 'console' => [ 76 | /** 77 | * Show a progress bar when generating feeds in the console. 78 | */ 79 | 'progress_bar' => (bool) env('FEED_CONSOLE_PROGRESS_BAR_ENABLED', false), 80 | ], 81 | 82 | /** 83 | * Transformers convert rich/complex values to simple scalar representations 84 | * suitable for feeds (XML/JSON). Order matters: the first transformer that 85 | * supports the value will handle it. 86 | * 87 | * You may add your own transformers by implementing 88 | * `DragonCode\LaravelFeed\Contracts\Transformer` and registering the class 89 | * here. 90 | */ 91 | 'transformers' => [ 92 | Transformers\BoolTransformer::class, 93 | Transformers\DateTimeTransformer::class, 94 | Transformers\EnumTransformer::class, 95 | // Transformers\NullTransformer::class, 96 | ], 97 | 98 | /** 99 | * Converters define low-level serialization settings for specific output 100 | * formats. You can tweak encoder flags and other options here. 101 | */ 102 | 'converters' => [ 103 | 'json' => [ 104 | /** 105 | * JSON encoding flags used when exporting feeds to JSON. 106 | */ 107 | 'options' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, 108 | ], 109 | 110 | 'jsonl' => [ 111 | /** 112 | * JSON encoding flags used when exporting feeds to JSON Lines format. 113 | * Pretty print is ignored for JSON Lines. 114 | */ 115 | 'options' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, 116 | ], 117 | 118 | 'csv' => [ 119 | /** 120 | * CSV specific options applied when exporting feeds in CSV format. 121 | */ 122 | /** 123 | * The delimiter used to separate values. Common values are "," or ";". 124 | */ 125 | 'delimiter' => ';', 126 | ], 127 | ], 128 | ]; 129 | -------------------------------------------------------------------------------- /src/Presets/Items/InstagramFeedItem.php: -------------------------------------------------------------------------------- 1 | title = $title; 53 | 54 | return $this; 55 | } 56 | 57 | public function description(string $description): static 58 | { 59 | $this->description = $description; 60 | 61 | return $this; 62 | } 63 | 64 | public function brand(string $name): static 65 | { 66 | $this->brand = $name; 67 | 68 | return $this; 69 | } 70 | 71 | public function url(string $url): static 72 | { 73 | $this->url = $url; 74 | 75 | return $this; 76 | } 77 | 78 | public function image(?string $url): static 79 | { 80 | $this->image = $url; 81 | 82 | return $this; 83 | } 84 | 85 | public function images(?array $urls): static 86 | { 87 | $this->images = $urls; 88 | 89 | return $this; 90 | } 91 | 92 | public function condition(?string $condition): static 93 | { 94 | $this->condition = $condition; 95 | 96 | return $this; 97 | } 98 | 99 | public function availability(?string $availability): static 100 | { 101 | $this->availability = $availability; 102 | 103 | return $this; 104 | } 105 | 106 | public function price(?float $price, ?float $salePrice = null): static 107 | { 108 | $this->price = $price; 109 | $this->salePrice = $salePrice ?? $price; 110 | 111 | return $this; 112 | } 113 | 114 | public function group(int|string|null $id): static 115 | { 116 | $this->groupId = (string) $id ?: null; 117 | 118 | return $this; 119 | } 120 | 121 | public function status(?string $status): static 122 | { 123 | $this->status = $status; 124 | 125 | return $this; 126 | } 127 | 128 | public function googleCategory(?int $id): static 129 | { 130 | $this->googleCategory = $id; 131 | 132 | return $this; 133 | } 134 | 135 | public function facebookCategory(?int $id): static 136 | { 137 | $this->facebookCategory = $id; 138 | 139 | return $this; 140 | } 141 | 142 | public function additional(array $additional): static 143 | { 144 | $this->additional = $additional; 145 | 146 | return $this; 147 | } 148 | 149 | public function toArray(): array 150 | { 151 | return collect([ 152 | 'g:id' => $this->model->getKey(), 153 | 154 | 'g:title' => ['@cdata' => $this->title], 155 | 'g:description' => ['@cdata' => $this->description], 156 | 157 | 'g:link' => $this->url, 158 | 'g:image_link' => $this->image, 159 | 160 | '@g:additional_image_link' => $this->images, 161 | 162 | 'g:brand' => $this->brand, 163 | 'g:condition' => $this->condition, 164 | 'g:availability' => $this->availability, 165 | 166 | 'g:price' => $this->price, 167 | 'g:sale_price' => $this->salePrice, 168 | 169 | 'g:item_group_id' => $this->groupId, 170 | 171 | 'g:status' => $this->status, 172 | 173 | 'g:google_product_category' => $this->googleCategory, 174 | 'g:fb_product_category' => $this->facebookCategory, 175 | ]) 176 | ->merge($this->additional) 177 | ->reject(static fn (mixed $value) => blank($value)) 178 | ->all(); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Services/GeneratorService.php: -------------------------------------------------------------------------------- 1 | started($feed); 35 | 36 | $this->export($feed, $output, $this->filesystem); 37 | 38 | $this->setLastActivity($feed); 39 | 40 | $this->finished($feed, $feed->path()); 41 | } catch (Throwable $e) { 42 | throw new FeedGenerationException(get_class($feed), $e); 43 | } 44 | } 45 | 46 | protected function export(Feed $feed, ?OutputStyle $output, FilesystemService $filesystem): void 47 | { 48 | (new ExportService($feed, $filesystem, $output)) 49 | ->file( 50 | create: $this->createFile($feed), 51 | close : $this->closeFile($feed) 52 | ) 53 | ->item(fn (Model $model, bool $last) => $this->converter($feed)->item( 54 | item : $feed->item($model), 55 | isLast: $last 56 | )) 57 | ->chunk($feed->chunkSize()) 58 | ->export(); 59 | } 60 | 61 | protected function createFile(Feed $feed): Closure 62 | { 63 | return function () use ($feed) { 64 | $file = $this->createDraft($feed->filename()); 65 | 66 | $this->performHeader($file, $feed); 67 | $this->performRoot($file, $feed, true); 68 | $this->performInfo($file, $feed); 69 | $this->performRoot($file, $feed, false); 70 | 71 | return $file; 72 | }; 73 | } 74 | 75 | protected function closeFile(Feed $feed): Closure 76 | { 77 | return function ($file, int $index) use ($feed) { 78 | $this->performFooter($file, $feed); 79 | 80 | $this->release($file, $feed->path($index)); 81 | }; 82 | } 83 | 84 | protected function performHeader($file, Feed $feed): void // @pest-ignore-type 85 | { 86 | $value = $this->converter($feed)->header($feed); 87 | 88 | $this->append($file, $value, $feed->path()); 89 | } 90 | 91 | protected function performInfo($file, Feed $feed): void // @pest-ignore-type 92 | { 93 | if (blank($info = $feed->info()->toArray())) { 94 | return; 95 | } 96 | 97 | $value = $this->converter($feed)->info($info, $feed->root()->beforeInfo); 98 | 99 | $this->append($file, $value . PHP_EOL, $feed->path()); 100 | } 101 | 102 | protected function performRoot($file, Feed $feed, bool $when): void // @pest-ignore-type 103 | { 104 | if ($feed->root()->beforeInfo !== $when) { 105 | return; 106 | } 107 | 108 | if (! $feed->root()->name) { 109 | return; 110 | } 111 | 112 | $value = $this->converter($feed)->root($feed); 113 | 114 | $this->append($file, $value, $feed->path()); 115 | } 116 | 117 | protected function performFooter($file, Feed $feed): void // @pest-ignore-type 118 | { 119 | $value = $this->converter($feed)->footer($feed); 120 | 121 | $this->append($file, $value, $feed->path()); 122 | } 123 | 124 | protected function append($file, string $content, string $path): void // @pest-ignore-type 125 | { 126 | if (blank($content)) { 127 | return; 128 | } 129 | 130 | $this->filesystem->append($file, $content, $path); 131 | } 132 | 133 | protected function release($file, string $path): void // @pest-ignore-type 134 | { 135 | $this->filesystem->release($file, $path); 136 | } 137 | 138 | protected function createDraft(string $filename) // @pest-ignore-type 139 | { 140 | return $this->filesystem->createDraft($filename); 141 | } 142 | 143 | protected function setLastActivity(Feed $feed): void 144 | { 145 | $this->query->setLastActivity( 146 | get_class($feed) 147 | ); 148 | } 149 | 150 | protected function converter(Feed $feed): Converter 151 | { 152 | return $this->converter->get( 153 | $feed->format() 154 | ); 155 | } 156 | 157 | protected function started(Feed $feed): void 158 | { 159 | event(new FeedStartingEvent(get_class($feed))); 160 | } 161 | 162 | protected function finished(Feed $feed, string $path): void 163 | { 164 | event(new FeedFinishedEvent(get_class($feed), $path)); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Services/ExportService.php: -------------------------------------------------------------------------------- 1 | perFile = $this->perFile($this->feed); 56 | $this->maxFiles = $this->maxFiles($this->feed); 57 | $this->total = $this->total(); 58 | $this->file = $this->fileIndex(); 59 | 60 | $this->left = $this->total; 61 | 62 | $this->progressBar = $this->createProgressBar( 63 | $this->total 64 | ); 65 | } 66 | 67 | public function chunk(int $chunk): static 68 | { 69 | $this->chunk = $chunk; 70 | 71 | return $this; 72 | } 73 | 74 | public function file(Closure $create, Closure $close): static 75 | { 76 | $this->createFile = $create; 77 | $this->closeFile = $close; 78 | 79 | return $this; 80 | } 81 | 82 | public function item(Closure $callback): static 83 | { 84 | $this->item = $callback; 85 | 86 | return $this; 87 | } 88 | 89 | public function export(): void 90 | { 91 | $this->feed->builder() 92 | ->lazyById($this->chunk) 93 | ->each(function (Model $model) { 94 | $this->records++; 95 | $this->left--; 96 | 97 | $this->content[] = value($this->item, $model, $this->isLastItem()); 98 | 99 | $this->store(); 100 | 101 | if ($this->left <= 0) { 102 | return false; 103 | } 104 | 105 | if ($this->maxFiles && $this->file >= $this->maxFiles) { 106 | return false; 107 | } 108 | }); 109 | 110 | $this->store(true); 111 | 112 | $this->progressBar?->finish(); 113 | } 114 | 115 | protected function store(bool $force = false): void 116 | { 117 | $whenRecords = $this->records >= $this->perFile; 118 | $whenLeft = $this->total && $this->left <= 0; 119 | $whenFile = $this->file > 1 && ! $this->content; 120 | 121 | if (! $force && $whenFile) { 122 | return; 123 | } 124 | 125 | if ($force || $whenRecords || $whenLeft) { 126 | $this->records = 0; 127 | 128 | if ($this->content || ! $this->fileCreated) { 129 | $this->append(); 130 | } 131 | 132 | $this->content = []; 133 | } 134 | 135 | if ($force || $whenRecords) { 136 | $this->releaseFile(); 137 | } 138 | } 139 | 140 | protected function isLastItem(): bool 141 | { 142 | return $this->records === $this->perFile || $this->left <= 0; 143 | } 144 | 145 | protected function getFile() // @pest-ignore-type 146 | { 147 | if (! empty($this->resource)) { 148 | return $this->resource; 149 | } 150 | 151 | $this->fileCreated = true; 152 | 153 | return $this->resource ??= value($this->createFile); 154 | } 155 | 156 | protected function releaseFile(): void 157 | { 158 | if ($this->resource === null) { 159 | return; 160 | } 161 | 162 | value($this->closeFile, $this->resource, $this->file); 163 | 164 | $this->resource = null; 165 | 166 | $this->file++; 167 | } 168 | 169 | protected function append(): void 170 | { 171 | $this->filesystem->append($this->getFile(), implode(PHP_EOL, $this->content), $this->feed->path()); 172 | } 173 | 174 | protected function perFile(Feed $feed): int 175 | { 176 | if ($count = max($feed->perFile(), 0)) { 177 | return $count; 178 | } 179 | 180 | return $this->modelCount(); 181 | } 182 | 183 | protected function maxFiles(Feed $feed): int 184 | { 185 | return max($feed->maxFiles(), 0); 186 | } 187 | 188 | protected function total(): int 189 | { 190 | if ($this->maxFiles === 0) { 191 | return $this->modelCount(); 192 | } 193 | 194 | return $this->perFile * $this->maxFiles; 195 | } 196 | 197 | protected function fileIndex(): int 198 | { 199 | if ($this->perFile === 0 || $this->perFile === $this->total) { 200 | return 0; 201 | } 202 | 203 | if ($this->perFile >= $this->total) { 204 | return 0; 205 | } 206 | 207 | return 1; 208 | } 209 | 210 | protected function modelCount(): int 211 | { 212 | return $this->modelCount ??= $this->feed->builder()->count(); 213 | } 214 | 215 | protected function createProgressBar(int $total): ?ProgressBar 216 | { 217 | return $this->output?->createProgressBar($total); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/Converters/XmlConverter.php: -------------------------------------------------------------------------------- 1 | document = new DOMDocument('1.0', 'UTF-8'); 40 | 41 | $this->document->formatOutput = $pretty; 42 | $this->document->preserveWhiteSpace = ! $pretty; 43 | } 44 | 45 | public function header(Feed $feed): string 46 | { 47 | if (empty($value = $feed->header())) { 48 | return ''; 49 | } 50 | 51 | return trim($value) . PHP_EOL; 52 | } 53 | 54 | public function footer(Feed $feed): string 55 | { 56 | $value = ''; 57 | 58 | if ($name = $feed->root()->name) { 59 | $value .= "\n\n\n"; 60 | } 61 | 62 | return $value . $feed->footer(); 63 | } 64 | 65 | public function root(Feed $feed): string 66 | { 67 | return ! empty($feed->root()->attributes) 68 | ? sprintf("<%s %s>\n\n", $feed->root()->name, $this->rootAttributes($feed->root())) 69 | : sprintf("<%s>\n\n", $feed->root()->name); 70 | } 71 | 72 | public function item(FeedItem $item, bool $isLast): string 73 | { 74 | $box = $this->performBox($item); 75 | 76 | $this->performItem($box, $item->toArray()); 77 | 78 | return $this->encode($box); 79 | } 80 | 81 | public function info(array $info, bool $afterRoot): string 82 | { 83 | $box = $this->document->createDocumentFragment(); 84 | 85 | $this->performItem($box, $info); 86 | 87 | return $this->encode($box); 88 | } 89 | 90 | protected function performBox(FeedItem $item): DOMNode 91 | { 92 | $element = $this->createElement($item->name()); 93 | 94 | if ($values = $item->attributes()) { 95 | $this->setAttributes($element, $values); 96 | } 97 | 98 | return $element; 99 | } 100 | 101 | protected function performItem(DOMNode $parent, array $items): void 102 | { 103 | foreach ($items as $key => $value) { 104 | $key = $this->convertKey($key); 105 | 106 | match (true) { 107 | $this->isAttributes($key) => $this->setAttributes($parent, $value), 108 | $this->isCData($key) => $this->setCData($parent, $value), 109 | $this->isMixed($key) => $this->setMixed($parent, $value), 110 | $this->isValue($key) => $this->setRaw($parent, $value), 111 | $this->isPrefixed($key) => $this->setItemsArray($parent, $value, $key), 112 | default => $this->setItems($parent, $key, $value), 113 | }; 114 | } 115 | } 116 | 117 | protected function isAttributes(string $key): bool 118 | { 119 | return $key === '@attributes'; 120 | } 121 | 122 | protected function isCData(string $key): bool 123 | { 124 | return $key === '@cdata'; 125 | } 126 | 127 | protected function isMixed(string $key): bool 128 | { 129 | return $key === '@mixed'; 130 | } 131 | 132 | protected function isValue(string $key): bool 133 | { 134 | return $key === '@value'; 135 | } 136 | 137 | protected function isPrefixed(string $key): bool 138 | { 139 | return str_starts_with($key, '@'); 140 | } 141 | 142 | protected function createElement(string $name, mixed $value = ''): DOMNode 143 | { 144 | return $this->document->createElement($name, (string) $this->transformValue($value)); 145 | } 146 | 147 | protected function setAttributes(DOMNode $element, array $attributes): void 148 | { 149 | foreach ($attributes as $key => $value) { 150 | $element->setAttribute($key, (string) $this->transformValue($value)); 151 | } 152 | } 153 | 154 | protected function setCData(DOMNode $element, string $value): void 155 | { 156 | $element->appendChild( 157 | $this->document->createCDATASection($value) 158 | ); 159 | } 160 | 161 | protected function setMixed(DOMNode $element, string $value): void 162 | { 163 | $fragment = $this->document->createDocumentFragment(); 164 | $fragment->appendXML($value); 165 | 166 | $element->appendChild($fragment); 167 | } 168 | 169 | protected function setItemsArray(DOMNode $parent, array $value, string $key): void 170 | { 171 | $key = Str::substr($key, 1); 172 | 173 | foreach ($value as $item) { 174 | $this->setItems($parent, $key, $item); 175 | } 176 | } 177 | 178 | protected function setItems(DOMNode $parent, string $key, mixed $value): void 179 | { 180 | $element = $this->createElement($key, is_array($value) ? '' : $value); 181 | 182 | if (is_array($value)) { 183 | $this->performItem($element, $value); 184 | } 185 | 186 | $parent->appendChild($element); 187 | } 188 | 189 | protected function setRaw(DOMNode $parent, mixed $value): void 190 | { 191 | $parent->nodeValue = (string) $this->transformValue($value); 192 | } 193 | 194 | protected function rootAttributes(ElementData $item): string 195 | { 196 | return collect($item->attributes) 197 | ->map(fn (mixed $value, int|string $key) => sprintf('%s="%s"', $key, $value)) 198 | ->implode(' '); 199 | } 200 | 201 | protected function encode(DOMNode $item): string 202 | { 203 | return $this->document->saveXML($item, LIBXML_COMPACT); 204 | } 205 | 206 | protected function convertKey(int|string $key): string 207 | { 208 | return str_replace(' ', '_', (string) $key); 209 | } 210 | } 211 | --------------------------------------------------------------------------------