cmelda / json-ld
PHP Schema.org JSON-LD builder with typed entities and automatic @graph references
Fund package maintenance!
Requires
- php: >=8.3
- ext-filter: *
- ext-json: *
Requires (Dev)
- arxeiss/coding-standards: ^0.14
- codeception/codeception: ^5.1
- codeception/module-asserts: ^3
- phpstan/phpstan: ^2.0
- rector/rector: ^2.0
README
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 anyThingtype- taxonomy:
DefinedTermandDefinedTermSet - audience:
AudienceandPeopleAudience - ownership:
setOwner()andaddOwner()on everyThing - areas served:
setAreaServed()onOrganization,LocalBusiness,OfferandContactPoint - product relations:
addIsAccessoryOrSparePartFor()andaddIsConsumableFor() - commerce shortcuts:
OfferShippingDetails::forCountry(),MerchantReturnPolicy::forCountry(),MerchantReturnPolicy::returnsNotPermitted()andMerchantReturnPolicy::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()acceptsCreativeWorkorEventand automatically adds the inverseaboutreference when the parent receives an@id, regardless of whethersetId()is called before or afteraddSubjectOf()
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)withsetInLanguage($language) thumbnail_url: usesetThumbnailUrl()upload_date: usesetUploadDate()duration_seconds: usesetDurationSeconds(), rendered as ISO 8601 durationcontent_url: usesetContentUrl()embed_url: usesetEmbedUrl()- publisher: use
setPublisher() - author: use
addAuthor() - media format and dimensions: use
setEncodingFormat(),setWidth()andsetHeight() - transcript/captions: use
setTranscript()andsetCaption() - license/copyright: use
setLicense(),setCopyrightHolder()andsetCopyrightYear() language: usesetInLanguage()
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.

