Compare commits

...
16 Commits
Author SHA1 Message Date
Joel Male a0d2837bd7 chore: bump version to 5.1.1 2026-04-11 08:32:33 +10:00
Joel Male 13d90a0f08 chore: update readme 2026-04-11 08:32:05 +10:00
Joel Male 37bc2b6af9 chore: bump version to 5.1.0 2026-04-11 08:27:29 +10:00
Joel Male 184e29003b ci: drop PHP 8.3 support, require 8.4+ 2026-04-11 08:25:42 +10:00
Joel Male 6a851de35e ci: add GitHub Actions workflow to run tests 2026-04-11 08:23:46 +10:00
Joel Male 4eac969816 chore: upgrade to Pest v4 2026-04-11 08:22:43 +10:00
Joel Male c4439309eb feat: add pint 2026-04-11 08:22:35 +10:00
Joel Male f618408877 test: fix addItem assertion and add xml parseability check 2026-04-11 08:19:20 +10:00
Joel Male cb067ccb77 test: fix quality issues and expand coverage for namespaced fields, empty feed, xml declaration 2026-04-11 08:17:47 +10:00
Joel Male af17f7b868 test: add Pest test suite for LaravelGoogleShoppingFeed 2026-04-11 08:15:11 +10:00
Joel Male d694586a65 chore: add Pest v3 testing infrastructure with Orchestra Testbench 2026-04-11 08:13:36 +10:00
Joel Male db8871110a refactor: address code quality - readonly props, const fields, void return, rss+xml content type 2026-04-11 08:12:00 +10:00
Joel Male 1c426d0d1b refactor: strict types, typed props, split toXml/generate, custom exception 2026-04-11 08:09:43 +10:00
Joel Male 8f0ae03c4b v5.0.0 2026-01-05 10:56:11 +10:00
Joel Male 428c310525 feat: laravel 12 support 2026-01-05 10:35:36 +10:00
Joel Male f9dcce39b9 chore: support Laravel 10 & 11 2024-01-08 09:26:53 +10:00
13 changed files with 9377 additions and 345 deletions
+34
View File
@@ -0,0 +1,34 @@
name: Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: ["8.4"]
name: PHP ${{ matrix.php }}
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Run tests
run: vendor/bin/pest
-29
View File
@@ -1,29 +0,0 @@
<?php
$finder = PhpCsFixer\Finder::create()
->exclude('bootstrap/cache')
->exclude('storage')
->exclude('vendor')
->in(__DIR__)
->name('*.php')
->notName('*.blade.php')
->ignoreDotFiles(true)
->ignoreVCS(true);
$rules = [
'@PSR12' => true,
'array_syntax' => ['syntax' => 'short'],
'binary_operator_spaces' => ['operators' => ['=>' => null]],
'linebreak_after_opening_tag' => true,
'ordered_imports' => ['sort_algorithm' => 'length'],
'phpdoc_order' => true,
'phpdoc_no_empty_return' => false,
'yoda_style' => false,
'no_unused_imports' => true,
];
$config = new PhpCsFixer\Config();
return $config->setRules($rules)
->setFinder($finder)
->setUsingCache(false);
+62 -11
View File
@@ -1,33 +1,84 @@
Laravel Google Shopping Feed ![Laravel Google Shopping Feed](docs/laravel-google-shopping-feed.png)
==============
A simple package to easily create an XML feed for Google Merchant Center to parse and retrieve your products on a frequent basis. [![Latest Version on Packagist](https://img.shields.io/packagist/v/wearepixel/laravel-google-shopping-feed.svg)](https://packagist.org/packages/wearepixel/laravel-google-shopping-feed)
[![Tests](https://img.shields.io/github/actions/workflow/status/wearepixel/laravel-google-shopping-feed/tests.yml?label=tests)](https://github.com/wearepixel/laravel-google-shopping-feed/actions/workflows/tests.yml)
[![PHP Version](https://img.shields.io/packagist/php-v/wearepixel/laravel-google-shopping-feed.svg)](https://packagist.org/packages/wearepixel/laravel-google-shopping-feed)
[![Total Downloads](https://img.shields.io/packagist/dt/wearepixel/laravel-google-shopping-feed.svg)](https://packagist.org/packages/wearepixel/laravel-google-shopping-feed)
[![License](https://img.shields.io/packagist/l/wearepixel/laravel-google-shopping-feed.svg)](https://packagist.org/packages/wearepixel/laravel-google-shopping-feed)
We recommend adding this to an API controller and generating it on the fly each time so you can be sure your products are always up to date within your merchant center. A simple package to easily create an XML feed for Google Merchant Center to parse and retrieve your products.
We recommend adding this to an API controller and generating it on the fly so your feed is always up to date.
## Requirements
- PHP 8.4+
- Laravel 10, 11, or 12
## Installation ## Installation
``` ```bash
composer require wearepixel/laravel-google-shopping-feed composer require wearepixel/laravel-google-shopping-feed
``` ```
## Example ## Usage
```php ```php
use Wearepixel\LaravelGoogleShoppingFeed\LaravelGoogleShoppingFeed; use Wearepixel\LaravelGoogleShoppingFeed\LaravelGoogleShoppingFeed;
$feed = LaravelGoogleShopping::init( $feed = LaravelGoogleShoppingFeed::init(
'Product Feed', title: 'My Store',
'App product Feed', description: 'My store product feed',
'https://mystore.com' link: 'https://mystore.com',
); );
$feed->addItem([ $feed->addItem([
'id' => 'item_001', 'id' => 'item_001',
'title' => 'Blue Nikes', 'title' => 'Blue Nikes',
'link' => 'https://mystore.com/products/blue-nikes', 'link' => 'https://mystore.com/products/blue-nikes',
'g:price' => '29.99 AUD',
'g:image_link' => 'https://mystore.com/images/blue-nikes-001.jpg', 'g:image_link' => 'https://mystore.com/images/blue-nikes-001.jpg',
'g:price' => 29.99,
]); ]);
return $feed->generate(); return $feed->generate();
``` ```
### Required item fields
| Field | Description |
| -------------- | ------------------------------------------------ |
| `id` | Unique product identifier |
| `title` | Product name |
| `link` | URL to the product page |
| `g:price` | Price including currency code (e.g. `29.99 AUD`) |
| `g:image_link` | URL to the main product image |
Any additional fields supported by the [Google Product Data Specification](https://support.google.com/merchants/answer/7052112) can be passed alongside the required fields.
### Methods
**`LaravelGoogleShoppingFeed::init(string $title, string $description, string $link): static`**
Creates a new feed instance with channel metadata.
**`$feed->addItem(array $item): void`**
Adds a product to the feed. Throws `MissingRequiredFieldException` if any required field is absent.
**`$feed->toXml(): string`**
Returns the feed as an XML string.
**`$feed->generate(): Response`**
Returns an HTTP response with the XML content and `application/rss+xml` content type. Suitable for returning directly from a controller.
## Testing
```bash
composer test
```
## License
MIT
+26 -5
View File
@@ -6,7 +6,7 @@
"laravel", "laravel",
"google-shopping-feed" "google-shopping-feed"
], ],
"version": "3.0.0", "version": "5.1.1",
"homepage": "https://github.com/wearepixel/laravel-google-shopping-feed", "homepage": "https://github.com/wearepixel/laravel-google-shopping-feed",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
@@ -16,11 +16,32 @@
} }
], ],
"autoload": { "autoload": {
"psr-4": { "Wearepixel\\LaravelGoogleShoppingFeed\\" : "src/" } "psr-4": {
"Wearepixel\\LaravelGoogleShoppingFeed\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}, },
"require": { "require": {
"spatie/array-to-xml": "2.8.*|3.*.*", "php": ">=8.4",
"illuminate/support": "8.*|9.*" "spatie/array-to-xml": "2.8.*|3.*",
"illuminate/support": "10.*|11.*|12.*|13.*"
}, },
"prefer-stable": true "prefer-stable": true,
"require-dev": {
"laravel/pint": "^1.29",
"pestphp/pest": "^4.0",
"orchestra/testbench": "^10.0"
},
"scripts": {
"test": "vendor/bin/pest"
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
} }
Generated
+8986 -257
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
+13
View File
@@ -0,0 +1,13 @@
{
"preset": "laravel",
"rules": {
"concat_space": {
"spacing": "one"
},
"class_attributes_separation": false,
"ordered_imports": {
"sort_algorithm": "length"
},
"not_operator_with_space": false
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Wearepixel\LaravelGoogleShoppingFeed\Exceptions;
use RuntimeException;
class MissingRequiredFieldException extends RuntimeException {}
+33 -35
View File
@@ -1,31 +1,33 @@
<?php <?php
declare(strict_types=1);
namespace Wearepixel\LaravelGoogleShoppingFeed; namespace Wearepixel\LaravelGoogleShoppingFeed;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Response as ResponseFacade;
use Spatie\ArrayToXml\ArrayToXml; use Spatie\ArrayToXml\ArrayToXml;
use Wearepixel\LaravelGoogleShoppingFeed\Exceptions\MissingRequiredFieldException;
class LaravelGoogleShoppingFeed class LaravelGoogleShoppingFeed
{ {
public $title; public readonly string $title;
public $description; public readonly string $description;
public $link; public readonly string $link;
protected $xml = []; protected array $products = [];
protected $products = [];
protected $currency = "AUD";
protected $requiredProductFields = [ protected const array REQUIRED_PRODUCT_FIELDS = [
'id', 'id',
'link', 'link',
'title', 'title',
'g:price', 'g:price',
'g:image_link' 'g:image_link',
]; ];
public static function init($title = null, $description = null, $link = null) public static function init(string $title = '', string $description = '', string $link = ''): static
{ {
$feed = new self(); $feed = new static;
$feed->title = $title; $feed->title = $title;
$feed->description = $description; $feed->description = $description;
$feed->link = $link; $feed->link = $link;
@@ -33,26 +35,20 @@ public static function init($title = null, $description = null, $link = null)
return $feed; return $feed;
} }
public function addItem(array $item): bool public function addItem(array $item): void
{ {
foreach ($this->requiredProductFields as $field) { foreach (self::REQUIRED_PRODUCT_FIELDS as $field) {
if (!isset($item[$field])) { if (! isset($item[$field])) {
throw new \Exception("Required field '{$field}' is missing"); throw new MissingRequiredFieldException("Required field '{$field}' is missing");
} }
} }
foreach ($item as $key => $value) { $this->products[] = $item;
$product[$key] = $value;
} }
$this->products[] = $product; public function toXml(): string
return true;
}
public function generate()
{ {
$this->xml = [ $data = [
'rss' => [ 'rss' => [
'_attributes' => [ '_attributes' => [
'xmlns:g' => 'http://base.google.com/ns/1.0', 'xmlns:g' => 'http://base.google.com/ns/1.0',
@@ -62,23 +58,25 @@ public function generate()
'title' => $this->title, 'title' => $this->title,
'description' => $this->description, 'description' => $this->description,
'link' => $this->link, 'link' => $this->link,
] ],
] ],
]; ];
foreach ($this->products as $key => $product) { foreach ($this->products as $key => $product) {
$this->xml['rss']['channel']['item_'.$key] = $product; $data['rss']['channel']['item_' . $key] = $product;
} }
$xml = ArrayToXml::convert($this->xml, ''); $xml = ArrayToXml::convert($data, '');
$xml = str_replace([' ', '<root>', '</root>', "\n", "\r", '<remove>remove</remove>'], '', $xml); $xml = str_replace([' ', '<root>', '</root>', "\n", "\r", '<remove>remove</remove>'], '', $xml);
$xml = preg_replace([ // spatie/array-to-xml does not support repeated keys, so products are keyed as
"/item_[0-9][0-9][0-9][0-9]/", // item_0, item_1, etc. and renamed back to <item> tags here.
"/item_[0-9][0-9][0-9]/", $xml = (string) preg_replace('/item_\d+/', 'item', $xml);
"/item_[0-9][0-9]/",
"/item_[0-9]/",
], "item", $xml);
return response($xml)->header('Content-Type', 'text/xml'); return $xml;
}
public function generate(): Response
{
return ResponseFacade::make($this->toXml(), headers: ['Content-Type' => 'application/rss+xml']);
} }
} }
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
use Wearepixel\LaravelGoogleShoppingFeed\Exceptions\MissingRequiredFieldException;
use Wearepixel\LaravelGoogleShoppingFeed\LaravelGoogleShoppingFeed;
function validProduct(array $overrides = []): array
{
return array_merge([
'id' => 'prod-1',
'link' => 'https://example.com/product',
'title' => 'Test Product',
'g:price' => '29.99 AUD',
'g:image_link' => 'https://example.com/image.jpg',
], $overrides);
}
describe('init', function () {
it('stores title, description, and link', function () {
$feed = LaravelGoogleShoppingFeed::init('My Store', 'My Desc', 'https://example.com');
expect($feed->title)->toBe('My Store')
->and($feed->description)->toBe('My Desc')
->and($feed->link)->toBe('https://example.com');
});
it('defaults to empty strings', function () {
$feed = LaravelGoogleShoppingFeed::init();
expect($feed->title)->toBe('')
->and($feed->description)->toBe('')
->and($feed->link)->toBe('');
});
});
describe('addItem', function () {
it('accepts a valid item without throwing', function () {
$feed = LaravelGoogleShoppingFeed::init('', '', '');
$feed->addItem(validProduct());
expect($feed->toXml())->toContain('prod-1');
});
it('throws for missing required field', function (string $field) {
$product = validProduct();
unset($product[$field]);
expect(fn () => LaravelGoogleShoppingFeed::init()->addItem($product))
->toThrow(MissingRequiredFieldException::class);
})->with(['id', 'link', 'title', 'g:price', 'g:image_link']);
it('throws with a message containing the field name', function () {
$product = validProduct();
unset($product['g:price']);
expect(fn () => LaravelGoogleShoppingFeed::init()->addItem($product))
->toThrow(MissingRequiredFieldException::class, "g:price");
});
});
describe('toXml', function () {
it('returns a string', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
expect($feed->toXml())->toBeString();
});
it('contains the RSS xmlns:g attribute', function () {
$feed = LaravelGoogleShoppingFeed::init();
$xml = $feed->toXml();
expect($xml)->toContain('xmlns:g="http://base.google.com/ns/1.0"');
});
it('contains the RSS version attribute', function () {
$feed = LaravelGoogleShoppingFeed::init();
$xml = $feed->toXml();
expect($xml)->toContain('version="2.0"');
});
it('includes the channel title, description and link', function () {
$feed = LaravelGoogleShoppingFeed::init('My Store', 'My Description', 'https://example.com');
$xml = $feed->toXml();
expect($xml)
->toContain('<title>My Store</title>')
->toContain('<description>My Description</description>')
->toContain('<link>https://example.com</link>');
});
it('includes product data', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct());
$xml = $feed->toXml();
expect($xml)
->toContain('<id>prod-1</id>')
->toContain('<title>Test Product</title>');
});
it('wraps products in item tags', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct());
$xml = $feed->toXml();
expect($xml)
->toContain('<item>')
->toContain('</item>');
});
it('handles multiple products', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct(['id' => 'prod-1']));
$feed->addItem(validProduct(['id' => 'prod-2']));
$xml = $feed->toXml();
expect(substr_count($xml, '<item>'))->toBe(2)
->and($xml)->toContain('<id>prod-1</id>')
->and($xml)->toContain('<id>prod-2</id>');
});
it('includes namespaced google fields in output', function () {
$feed = LaravelGoogleShoppingFeed::init('', '', '');
$feed->addItem(validProduct());
$xml = $feed->toXml();
expect($xml)
->toContain('<g:price>29.99 AUD</g:price>')
->toContain('<g:image_link>https://example.com/image.jpg</g:image_link>');
});
it('generates valid xml with no products', function () {
$feed = LaravelGoogleShoppingFeed::init('My Store', 'My Desc', 'https://example.com');
$xml = $feed->toXml();
expect($xml)
->toBeString()
->toContain('<title>My Store</title>')
->not->toContain('<item>');
});
it('includes the xml declaration', function () {
$feed = LaravelGoogleShoppingFeed::init('', '', '');
expect($feed->toXml())->toStartWith('<?xml');
});
it('generates parseable xml', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct());
$result = simplexml_load_string($feed->toXml());
expect($result)->not->toBeFalse();
});
});
describe('generate', function () {
it('returns an HTTP 200 response', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$response = $feed->generate();
expect($response->getStatusCode())->toBe(200);
});
it('sets the content type to application/rss+xml', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$response = $feed->generate();
expect($response->headers->get('Content-Type'))->toContain('application/rss+xml');
});
it('returns the xml in the response body', function () {
$feed = LaravelGoogleShoppingFeed::init('Store', 'Desc', 'https://example.com');
$feed->addItem(validProduct());
$response = $feed->generate();
expect($response->getContent())->toBe($feed->toXml());
});
});
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
uses(Tests\TestCase::class)->in('.');
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Tests;
use Orchestra\Testbench\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase {}