cmelda/json-ld

PHP Schema.org JSON-LD builder with typed entities and automatic @graph references

Maintainers

Package info

gitlab.com/cmelda/json-ld

Issues

pkg:composer/cmelda/json-ld

Transparency log

Fund package maintenance!

Ko-Fi

Statistics

Installs: 30

Dependents: 0

Suggesters: 0

Stars: 0

v2.4.0 2026-08-23 17:52 UTC

This package is auto-updated.

Last update: 2026-08-23 15:52:54 UTC


README

Latest Stable Version Total Downloads License PHP Version Require

PHP Schema.org JSON-LD builder with typed entities and automatic @graph references. Build standalone Schema.org entities or connected documents without manually maintaining graph nodes and @id references.

What this library is and is not

This library provides an ergonomic, typed PHP API for Schema.org structured data. Entities with an @id are linked by reference and automatically added to the document @graph once. Entities without an @id remain inline unless they are document roots.

It is not a general-purpose JSON-LD processor and does not implement JSON-LD expansion, compaction, framing or flattening algorithms. Use it when you want to generate Schema.org JSON-LD, especially when several reusable entities need to be connected safely in one graph.

Installation

composer require cmelda/json-ld

Standalone entity

use Cmelda\JsonLd\Types\Person;

echo Person::make('https://example.com', 'person')
	->setName('George')
	->toScript();

Graph document

use Cmelda\JsonLd\Core\JsonLdDocument;
use Cmelda\JsonLd\Types\Article;
use Cmelda\JsonLd\Types\Person;

$person = Person::make('https://example.com', 'person')->setName('George');
$article = Article::make('https://example.com/article')
	->setHeadline('Sample article')
	->addAuthor($person)
	->addEditor($person);

echo JsonLdDocument::make()->add($article)->toScript();

The article contains two {"@id":"https://example.com#person"} references, but the full person is present in @graph only once.

Adding an @id changes serialization semantics: when such an entity is nested inside another entity, only its @id reference is stored there and its complete representation is emitted as a separate graph node. setUrl() only writes the Schema.org url property; use fromUrl() or setId() when an @id is needed.

Entity IDs accept JSON-LD IRI references: absolute IRIs such as an HTTP(S) URL, urn:uuid:... or mailto:..., relative references such as #person or /people/alex, and explicit blank-node identifiers such as _:person. Absolute stable IDs are recommended for reusable public entities. Values with whitespace, unsafe IRI delimiters or malformed percent encoding are rejected. URL-valued Schema.org properties intentionally remain stricter and accept only absolute HTTP/HTTPS URLs.

The same anonymous PHP object used repeatedly as a document root is emitted once. Two different anonymous objects remain two distinct graph nodes even when their values are identical.

Graph nodes preserve their discovery order even when identified and anonymous roots are mixed. This makes fixtures and output diffs stable without changing JSON-LD semantics.

JsonLdDocument implements JsonSerializable, so json_encode($document) uses the same data as toArray(). For debugging or integration assertions, use getRootEntities(), getEntities() and findById() to inspect the live graph without parsing rendered JSON. The registry is rebuilt on every lookup, so changes made to an entity after adding it to a document are visible.

Shared organization

use Cmelda\JsonLd\Core\JsonLdDocument;
use Cmelda\JsonLd\Types\Article;
use Cmelda\JsonLd\Types\Organization;
use Cmelda\JsonLd\Types\Person;

$organization = Organization::make('https://example.com', 'organization')
	->setName('Company');
$person = Person::make('https://example.com', 'person')
	->setName('George')
	->setWorksFor($organization);
$article = Article::make('https://example.com/article')
	->addAuthor($person)
	->setPublisher($organization);

echo JsonLdDocument::make()->add($article)->toScript();

Every parent stores an @id reference and the shared organization appears in @graph once.

Inline and linked images

use Cmelda\JsonLd\Types\ImageObject;

$inline = ImageObject::make()->setUrl('https://example.com/image.jpg');
$linked = ImageObject::make('https://example.com/image.jpg')
	->setUrl('https://example.com/image.jpg');

An image without an @id is embedded inline. An image with an @id is linked by reference and added to the graph.

Standalone rendering is rejected when an entity has linked dependencies. Render the document in that case. Entities remain reusable after they have been added to a JsonLdDocument.

FAQ page

use Cmelda\JsonLd\Types\Answer;
use Cmelda\JsonLd\Types\FAQPage;
use Cmelda\JsonLd\Types\Question;

$faqPage = FAQPage::make()
	->addMainEntity(
		Question::make()
			->setName('How long is the warranty?')
			->setAcceptedAnswer(Answer::make()->setText('The warranty is two years.')),
	);

echo $faqPage->toScript();

Use PropertyValue for Schema.org property values. The deprecated AdditionalProperty class name remains available for compatibility. Use VideoObject for Schema.org video metadata.

Practical metadata helpers

The library includes small helpers for common structured data metadata without trying to wrap the whole Schema.org vocabulary:

  • sameAs: addSameAs() on any Thing type
  • taxonomy: DefinedTerm and DefinedTermSet
  • audience: Audience and PeopleAudience
  • ownership: setOwner() and addOwner() on every Thing
  • areas served: setAreaServed() on Organization, LocalBusiness, Offer and ContactPoint
  • product relations: addIsAccessoryOrSparePartFor() and addIsConsumableFor()
  • commerce shortcuts: OfferShippingDetails::forCountry(), MerchantReturnPolicy::forCountry(), MerchantReturnPolicy::returnsNotPermitted() and MerchantReturnPolicy::unlimitedReturns()
  • Schema.org 30.0: credentials, educational credentials, administrative areas, diets, invoices, orders, recurring schedules, instantaneous events, errors, status enumerations and non-profit organization types
  • organization profiles: workforce size, founding details, hierarchy, membership, legal representatives, certifications and business identifiers
  • content about a thing: addSubjectOf() accepts CreativeWork or Event and automatically adds the inverse about reference when the parent receives an @id, regardless of whether setId() is called before or after addSubjectOf()

Custom properties

Prefer typed setters whenever the property is supported. For a Schema.org extension or a property not covered by the current typed API, use the explicit escape hatch:

$product->setCustomProperty('customStatus', 'verified');

Custom property names must be non-empty, contain no whitespace and must not begin with @. JSON-LD keywords such as @id and @type are controlled by the entity API and cannot be overridden.

The generic public set() method remains available for compatibility in 2.x, but is marked @internal. It is planned to become protected in 3.0.0 after the typed API has been expanded further. New consumer code should not call it.

Schema.org enumerations

For common Schema.org enum values, prefer library enums instead of raw strings:

use Cmelda\JsonLd\Enums\ItemAvailability;
use Cmelda\JsonLd\Enums\ItemCondition;
use Cmelda\JsonLd\Types\Offer;
use Cmelda\JsonLd\Types\Product;

$product = Product::make()
	->setItemCondition(ItemCondition::New);

$offer = Offer::make()
	->setAvailability(ItemAvailability::InStock);

The setters still accept a string URL for new Schema.org values that are not yet present in the library.

Supported Schema.org version

This release targets the Schema.org version declared in the single resources/schema-org/manifest.json source of truth. The manifest identifies a versioned vocabulary snapshot and its SHA-256 checksum, so regular tests and composer schema:coverage are deterministic, integrity-checked and do not use the internet.

Maintainers can check for a new stable release with composer schema:check-latest, inspect it with composer schema:diff and update the pinned version with composer schema:update. See Schema.org version support for the complete offline coverage, update and scheduled-CI workflow.

To see exactly which properties are implemented or missing for a type, run:

composer schema:coverage -- --type=Product

Use --details for every supported type or --unsupported for the complete list of vocabulary types not currently declared as supported.

Offer price specification

Use priceSpecification when you need detailed offer pricing, unit pricing or compound price components:

use Cmelda\JsonLd\Types\Offer;
use Cmelda\JsonLd\Types\UnitPriceSpecification;

$offer = Offer::make()
	->addPriceSpecification(
		UnitPriceSpecification::make()
			->setPrice('49.90')
			->setPriceCurrency('USD')
			->setValueAddedTaxIncluded(true),
	);

Video metadata

VideoObject maps common CMS fields to Schema.org VideoObject properties and extends MediaObject, which extends CreativeWork, which extends Thing. Attach videos to products, offers or other things through addSubjectOf():

use Cmelda\JsonLd\Types\Product;
use Cmelda\JsonLd\Types\VideoObject;

$product = Product::fromUrl('https://example.com/products/widget')
	->setName('Widget')
	->addSubjectOf(
		VideoObject::make('https://example.com/products/widget', 'video')
			->setTitle('Widget overview', 'en-US')
			->setThumbnailUrl('https://example.com/video.jpg')
			->setUploadDate('2026-02-04T10:00:00+01:00'),
	);

Product.subjectOf contains a reference to the video and the VideoObject contains about with a reference back to the product.

Common video fields:

  • localized video title fields: use setTitle($title, $language)
  • localized video descriptions: use setDescription($description) with setInLanguage($language)
  • thumbnail_url: use setThumbnailUrl()
  • upload_date: use setUploadDate()
  • duration_seconds: use setDurationSeconds(), rendered as ISO 8601 duration
  • content_url: use setContentUrl()
  • embed_url: use setEmbedUrl()
  • publisher: use setPublisher()
  • author: use addAuthor()
  • media format and dimensions: use setEncodingFormat(), setWidth() and setHeight()
  • transcript/captions: use setTranscript() and setCaption()
  • license/copyright: use setLicense(), setCopyrightHolder() and setCopyrightYear()
  • language: use setInLanguage()

The legacy setVideoSource() and setActive() helpers are deprecated because Schema.org does not define additionalProperty for VideoObject. Prefer the dedicated video properties above and do not emit application-only fields as Schema.org data.

Factory fromUrl()

fromUrl() is an explicit way to create an @id from a URL. setUrl() only sets the url property and never changes @id.

use Cmelda\JsonLd\Types\Person;

$person = Person::fromUrl('https://example.com/authors/alex')
	->setName('Alex');

Use make($id, $fragment) when you already have an absolute or relative IRI reference and want a specific fragment @id without manually concatenating #fragment:

use Cmelda\JsonLd\Types\LocalBusiness;

$store = LocalBusiness::make('https://example.com/store', 'store-ostrava')
	->setName('Ostrava store');

Person, Organization, Product and LocalBusiness use a type-specific URL fragment. Article, BlogPosting and TechArticle use their URL directly as @id. A review uses Review::fromUrlAndId() because one product page can contain multiple reviews.

Article types

Use Article for generic article content, BlogPosting for blog posts and TechArticle for technical documentation or tutorials. BlogPosting follows the Schema.org hierarchy through SocialMediaPosting and then Article:

use Cmelda\JsonLd\Types\BlogPosting;
use Cmelda\JsonLd\Types\TechArticle;

$post = BlogPosting::fromUrl('https://example.com/blog/widget-guide')
	->setHeadline('Widget guide')
	->setBlogSection('Guides');

$technical = TechArticle::fromUrl('https://example.com/docs/widget-api')
	->setHeadline('Widget API')
	->setProficiencyLevel('Beginner');

Use SoftwareSourceCode when you need codeRepository or programmingLanguage; those properties do not belong to TechArticle.

Reviews

For a regular customer review, keep the customer, review and rating inline without an @id:

use Cmelda\JsonLd\Types\Product;
use Cmelda\JsonLd\Types\Rating;
use Cmelda\JsonLd\Types\Review;

$review = Review::make()
	->setAuthor('Jamie K.')
	->setReviewBody('The widget works perfectly.')
	->setReviewRating(Rating::make()->setRatingValue(5)->setBestRating(5));

$product = Product::fromUrl('https://example.com/products/nimbus-widget')
	->setName('Nimbus Widget')
	->addReview($review);

For an expert review, use Person::fromUrl() for the author's profile and Review::fromUrlAndId() for the stable review identifier. The product contains a review reference, the review contains an author reference and both entities are added to @graph.

Validation fixture

The JSON files in tests/Support/Data/ contain complete examples suitable for manual validation with online JSON-LD tools. The unit test suite compares the fixtures with documents generated by the library.

More extensive PHP examples with their JSON outputs are available in docs/. Smaller recipes are collected in advanced usage patterns, with a cheatsheet and common mistakes for faster integration.

Migrating from v1

Version 2 uses consistent addX() methods for properties with multiple values:

$product
	->addImage('https://example.com/image.jpg')
	->addReview($review);

$offer->addShippingDetails($shippingDetails);
$organization->addContactPoint($contactPoint);

Where both forms exist, setX() replaces the complete current property value and addX() appends one value. Calling addX() after setX() therefore turns a single value into a list; calling setX() afterwards replaces that list.

Product::setBrand() accepts Brand|string and Product::setAggregateRating() accepts AggregateRating. Entities remain reusable and their @id can be changed after adding them to a document.

License

Distributed under the MIT License. See LICENSE.txt for more information.

Donation

ko-fi